diff --git a/.gitignore b/.gitignore index 8548445..bce6d60 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,4 @@ bin ref/ .playwright-mcp/ canvas-*.png +.vercel diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..774a68d --- /dev/null +++ b/.vercelignore @@ -0,0 +1,2 @@ +/* +!/site diff --git a/app/backend/cmd/dumptemplates/main.go b/app/backend/cmd/dumptemplates/main.go new file mode 100644 index 0000000..8d3ecaf --- /dev/null +++ b/app/backend/cmd/dumptemplates/main.go @@ -0,0 +1,24 @@ +// dumptemplates prints the seeded official template catalog as JSON in the same +// shape as GET /api/v1/templates. The landing site (site/templates.json) uses it +// as the offline fallback catalog: `go run ./cmd/dumptemplates > ../../site/templates.json`. +package main + +import ( + "containr/internal/api" + "encoding/json" + "fmt" + "os" +) + +func main() { + out := struct { + Templates []api.ServiceTemplate `json:"templates"` + }{Templates: api.SeedTemplates()} + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintln(os.Stderr, "encode:", err) + os.Exit(1) + } +} diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go index 8f05600..d530cf5 100644 --- a/app/backend/internal/api/templates.go +++ b/app/backend/internal/api/templates.go @@ -28,6 +28,7 @@ type ServiceTemplate struct { Variables string `json:"variables" db:"variables"` IsOfficial bool `json:"is_official" db:"is_official"` OwnerID string `json:"owner_id,omitempty" db:"owner_id"` + IsPublic bool `json:"is_public" db:"is_public"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } @@ -383,6 +384,7 @@ type templateWriteRequest struct { Logo string `json:"logo"` Config json.RawMessage `json:"config" binding:"required"` Variables json.RawMessage `json:"variables"` + IsPublic bool `json:"is_public"` } var templateCategories = map[string]bool{ @@ -483,6 +485,7 @@ func handleCreateTemplate(c *gin.Context) { Config: req.Config, Variables: templateVariablesParam(req.Variables), OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, + IsPublic: req.IsPublic, }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"}) return @@ -529,6 +532,7 @@ func handleUpdateTemplate(c *gin.Context) { Logo: nullableText(req.Logo), Config: req.Config, Variables: templateVariablesParam(req.Variables), + IsPublic: req.IsPublic, ID: templateID, OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, }) @@ -1013,10 +1017,11 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) { } } -// templateVisibleTo reports whether the caller may see a template: official -// templates are public, user templates are owner/admin only. +// templateVisibleTo reports whether the caller may see a template: official and +// published (is_public) templates are public, other user templates are +// owner/admin only. func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool { - if t.IsOfficial { + if t.IsOfficial || t.IsPublic { return true } if contextIsAdmin(c) { @@ -1047,6 +1052,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate { Variables: variables, IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool, OwnerID: ownerID, + IsPublic: row.IsPublic, CreatedAt: templateNullTime(row.CreatedAt), UpdatedAt: templateNullTime(row.UpdatedAt), } diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 0d39eb3..b424a62 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -207,6 +207,7 @@ type ServiceTemplate struct { Variables pqtype.NullRawMessage `json:"variables"` IsOfficial sql.NullBool `json:"is_official"` OwnerID uuid.NullUUID `json:"owner_id"` + IsPublic bool `json:"is_public"` CreatedAt sql.NullTime `json:"created_at"` UpdatedAt sql.NullTime `json:"updated_at"` } diff --git a/app/backend/internal/database/sqlcdb/templates.sql.go b/app/backend/internal/database/sqlcdb/templates.sql.go index 526b650..36b8ef2 100644 --- a/app/backend/internal/database/sqlcdb/templates.sql.go +++ b/app/backend/internal/database/sqlcdb/templates.sql.go @@ -125,7 +125,7 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi } const createUserTemplate = `-- name: CreateUserTemplate :exec -INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) +INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public) VALUES ( $1, $2, @@ -135,7 +135,8 @@ VALUES ( $6, $7, false, - $8 + $8, + $9 ) ` @@ -148,6 +149,7 @@ type CreateUserTemplateParams struct { Config json.RawMessage `json:"config"` Variables pqtype.NullRawMessage `json:"variables"` OwnerID uuid.NullUUID `json:"owner_id"` + IsPublic bool `json:"is_public"` } func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error { @@ -160,6 +162,7 @@ func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplate arg.Config, arg.Variables, arg.OwnerID, + arg.IsPublic, ) return err } @@ -198,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI } const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates WHERE id = $1 ` @@ -216,6 +219,7 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic &i.Variables, &i.IsOfficial, &i.OwnerID, + &i.IsPublic, &i.CreatedAt, &i.UpdatedAt, ) @@ -223,10 +227,10 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic } const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates WHERE category = $1 - AND (is_official = true OR owner_id = $2) + AND (is_official = true OR is_public = true OR owner_id = $2) ORDER BY is_official DESC, name ASC ` @@ -254,6 +258,7 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg &i.Variables, &i.IsOfficial, &i.OwnerID, + &i.IsPublic, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -271,9 +276,9 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg } const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates -WHERE is_official = true OR owner_id = $1 +WHERE is_official = true OR is_public = true OR owner_id = $1 ORDER BY is_official DESC, name ASC ` @@ -296,6 +301,7 @@ func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid. &i.Variables, &i.IsOfficial, &i.OwnerID, + &i.IsPublic, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -320,9 +326,10 @@ SET name = $1, logo = $4, config = $5, variables = $6, + is_public = $7, updated_at = NOW() -WHERE id = $7 - AND owner_id = $8 +WHERE id = $8 + AND owner_id = $9 AND is_official = false ` @@ -333,6 +340,7 @@ type UpdateUserTemplateParams struct { Logo sql.NullString `json:"logo"` Config json.RawMessage `json:"config"` Variables pqtype.NullRawMessage `json:"variables"` + IsPublic bool `json:"is_public"` ID string `json:"id"` OwnerID uuid.NullUUID `json:"owner_id"` } @@ -345,6 +353,7 @@ func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplate arg.Logo, arg.Config, arg.Variables, + arg.IsPublic, arg.ID, arg.OwnerID, ) diff --git a/app/backend/migrations_goose/20260924120000_template_publishing.sql b/app/backend/migrations_goose/20260924120000_template_publishing.sql new file mode 100644 index 0000000..6e28fab --- /dev/null +++ b/app/backend/migrations_goose/20260924120000_template_publishing.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Railway-style template publishing: owners can mark a user template public so +-- it appears in the anonymous catalog (GET /api/v1/templates) and on the +-- landing page. Official templates stay official; is_public only matters for +-- user templates. +ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT false; +CREATE INDEX IF NOT EXISTS idx_service_templates_public ON public.service_templates(is_public) WHERE is_public; + +-- +goose Down +DROP INDEX IF EXISTS public.idx_service_templates_public; +ALTER TABLE public.service_templates DROP COLUMN IF EXISTS is_public; diff --git a/app/backend/sqlc/queries/templates.sql b/app/backend/sqlc/queries/templates.sql index 25d2de4..3830c00 100644 --- a/app/backend/sqlc/queries/templates.sql +++ b/app/backend/sqlc/queries/templates.sql @@ -1,18 +1,18 @@ -- name: ListServiceTemplatesForUser :many -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates -WHERE is_official = true OR owner_id = sqlc.narg(owner_id) +WHERE is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id) ORDER BY is_official DESC, name ASC; -- name: ListServiceTemplatesByCategoryForUser :many -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates WHERE category = sqlc.arg(category) - AND (is_official = true OR owner_id = sqlc.narg(owner_id)) + AND (is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id)) ORDER BY is_official DESC, name ASC; -- name: GetServiceTemplateByID :one -SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at +SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at FROM service_templates WHERE id = $1; @@ -39,7 +39,7 @@ SET name = EXCLUDED.name, updated_at = NOW(); -- name: CreateUserTemplate :exec -INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id) +INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public) VALUES ( sqlc.arg(id), sqlc.arg(name), @@ -49,7 +49,8 @@ VALUES ( sqlc.arg(config), sqlc.narg(variables), false, - sqlc.arg(owner_id) + sqlc.arg(owner_id), + sqlc.arg(is_public) ); -- name: UpdateUserTemplate :execrows @@ -60,6 +61,7 @@ SET name = sqlc.arg(name), logo = sqlc.narg(logo), config = sqlc.arg(config), variables = sqlc.narg(variables), + is_public = sqlc.arg(is_public), updated_at = NOW() WHERE id = sqlc.arg(id) AND owner_id = sqlc.arg(owner_id) diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index a4e69bf..538b658 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -84,6 +84,7 @@ CREATE TABLE service_templates ( variables JSONB DEFAULT '[]', is_official BOOLEAN DEFAULT false, owner_id UUID, + is_public BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); diff --git a/app/frontend/src/features/templates/pages/TemplatesPage.tsx b/app/frontend/src/features/templates/pages/TemplatesPage.tsx index a9def7c..628d5ec 100644 --- a/app/frontend/src/features/templates/pages/TemplatesPage.tsx +++ b/app/frontend/src/features/templates/pages/TemplatesPage.tsx @@ -48,6 +48,7 @@ const demoTemplates: TemplateEntity[] = [ variablesRaw: '[]', isOfficial: true, ownerId: null, + isPublic: false, }, { id: 'tpl-go', @@ -59,6 +60,7 @@ const demoTemplates: TemplateEntity[] = [ variablesRaw: '[]', isOfficial: true, ownerId: null, + isPublic: false, }, { id: 'tpl-postgres', @@ -70,6 +72,7 @@ const demoTemplates: TemplateEntity[] = [ variablesRaw: '[]', isOfficial: true, ownerId: null, + isPublic: false, }, { id: 'tpl-mysql', @@ -81,6 +84,7 @@ const demoTemplates: TemplateEntity[] = [ variablesRaw: '[]', isOfficial: true, ownerId: null, + isPublic: false, }, { id: 'tpl-mariadb', @@ -92,6 +96,7 @@ const demoTemplates: TemplateEntity[] = [ variablesRaw: '[]', isOfficial: true, ownerId: null, + isPublic: false, }, { id: 'tpl-clickhouse', @@ -103,6 +108,7 @@ const demoTemplates: TemplateEntity[] = [ variablesRaw: '[]', isOfficial: true, ownerId: null, + isPublic: false, }, { id: 'tpl-dragonfly', @@ -114,6 +120,7 @@ const demoTemplates: TemplateEntity[] = [ variablesRaw: '[]', isOfficial: true, ownerId: null, + isPublic: false, }, ]; @@ -445,6 +452,7 @@ export function TemplatesPage() { const [editorOpen, setEditorOpen] = useState(false); const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON); const [editingTemplate, setEditingTemplate] = useState(null); + const [editorIsPublic, setEditorIsPublic] = useState(false); const [editorError, setEditorError] = useState(null); const [categoryFilter, setCategoryFilter] = useState(''); @@ -655,6 +663,7 @@ export function TemplatesPage() { const openCreateEditor = () => { setEditingTemplate(null); setEditorJson(EMPTY_TEMPLATE_JSON); + setEditorIsPublic(false); setEditorError(null); setEditorOpen(true); }; @@ -687,6 +696,7 @@ export function TemplatesPage() { 2, ), ); + setEditorIsPublic(template.isPublic); setEditorError(null); setEditorOpen(true); }; @@ -711,6 +721,7 @@ export function TemplatesPage() { logo: body.logo ?? '', config: body.config as Record, variables: Array.isArray(body.variables) ? body.variables : [], + isPublic: editorIsPublic, }); }; @@ -880,6 +891,11 @@ export function TemplatesPage() { Mine ) : null} + {!template.isOfficial && template.isPublic ? ( + + Public + + ) : null}
)} + {!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && ( + + Public + + )}

{selectedDetail.template.description}

@@ -1230,9 +1251,15 @@ export function TemplatesPage() { ) : null}
-

- Saved as a user template — only you (and admins) can edit or delete it. -

+
+
+

+
+

Runtime

+
+
+ +
+ + Deploy guide +
+

In your instance: Templates → New template → Upload .json (or paste the copied JSON). Deploy it to any project from there.

+
+ + + + + + + diff --git a/site/templates.json b/site/templates.json new file mode 100644 index 0000000..0f84ccf --- /dev/null +++ b/site/templates.json @@ -0,0 +1,394 @@ +{ + "templates": [ + { + "id": "tpl-nodejs", + "name": "Node.js Application", + "description": "Generic Node.js application with automatic dependency detection", + "category": "web", + "logo": "https://cdn.simpleicons.org/node.js", + "config": "{\"type\":\"web\",\"runtime\":\"node\",\"build_command\":\"npm install \u0026\u0026 npm run build\",\"start_command\":\"npm start\",\"port\":3000,\"health_check\":\"/health\"}", + "variables": "[{\"key\":\"NODE_ENV\",\"label\":\"Node Environment\",\"default\":\"production\",\"required\":false,\"secret\":false},{\"key\":\"NPM_TOKEN\",\"label\":\"NPM Token\",\"default\":\"\",\"required\":false,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-react", + "name": "React Application", + "description": "React single-page application with Vite", + "category": "frontend", + "logo": "https://cdn.simpleicons.org/react", + "config": "{\"type\":\"web\",\"runtime\":\"node\",\"build_command\":\"npm install \u0026\u0026 npm run build\",\"start_command\":\"npx serve -s dist\",\"port\":3000}", + "variables": "[{\"key\":\"VITE_API_URL\",\"label\":\"API URL\",\"default\":\"\",\"required\":true,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-python", + "name": "Python Application", + "description": "Python application with FastAPI/Flask support", + "category": "web", + "logo": "https://cdn.simpleicons.org/python", + "config": "{\"type\":\"web\",\"runtime\":\"python\",\"build_command\":\"pip install -r requirements.txt\",\"start_command\":\"python main.py\",\"port\":8000}", + "variables": "[{\"key\":\"PYTHON_VERSION\",\"label\":\"Python Version\",\"default\":\"3.11\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-go", + "name": "Go Application", + "description": "Go backend service", + "category": "web", + "logo": "https://cdn.simpleicons.org/go", + "config": "{\"type\":\"web\",\"runtime\":\"go\",\"build_command\":\"go build -o app .\",\"start_command\":\"./app\",\"port\":8080}", + "variables": "[{\"key\":\"GO_VERSION\",\"label\":\"Go Version\",\"default\":\"1.21\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-postgres", + "name": "PostgreSQL Database", + "description": "Managed PostgreSQL database", + "category": "database", + "logo": "https://cdn.simpleicons.org/postgresql", + "config": "{\"type\":\"database\",\"runtime\":\"postgres\",\"port\":5432}", + "variables": "[{\"key\":\"POSTGRES_USER\",\"label\":\"Username\",\"default\":\"postgres\",\"required\":true,\"secret\":false},{\"key\":\"POSTGRES_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"POSTGRES_DB\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-redis", + "name": "Redis Cache", + "description": "In-memory data store", + "category": "database", + "logo": "https://cdn.simpleicons.org/redis", + "config": "{\"type\":\"database\",\"runtime\":\"redis\",\"port\":6379}", + "variables": "[{\"key\":\"REDIS_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-mongodb", + "name": "MongoDB Database", + "description": "NoSQL document database", + "category": "database", + "logo": "https://cdn.simpleicons.org/mongodb", + "config": "{\"type\":\"database\",\"runtime\":\"mongodb\",\"port\":27017}", + "variables": "[{\"key\":\"MONGO_INITDB_ROOT_USERNAME\",\"label\":\"Root Username\",\"default\":\"admin\",\"required\":true,\"secret\":false},{\"key\":\"MONGO_INITDB_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-mysql", + "name": "MySQL Database", + "description": "Managed MySQL database service", + "category": "database", + "logo": "https://cdn.simpleicons.org/mysql", + "config": "{\"type\":\"database\",\"runtime\":\"mysql\",\"port\":3306}", + "variables": "[{\"key\":\"MYSQL_DATABASE\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MYSQL_USER\",\"label\":\"Username\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MYSQL_PASSWORD\",\"label\":\"User Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"MYSQL_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-mariadb", + "name": "MariaDB Database", + "description": "Managed MariaDB database service", + "category": "database", + "logo": "https://cdn.simpleicons.org/mariadb", + "config": "{\"type\":\"database\",\"runtime\":\"mariadb\",\"port\":3306}", + "variables": "[{\"key\":\"MARIADB_DATABASE\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MARIADB_USER\",\"label\":\"Username\",\"default\":\"app\",\"required\":true,\"secret\":false},{\"key\":\"MARIADB_PASSWORD\",\"label\":\"User Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"MARIADB_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-clickhouse", + "name": "ClickHouse Database", + "description": "Column-oriented analytics database", + "category": "database", + "logo": "https://cdn.simpleicons.org/clickhouse", + "config": "{\"type\":\"database\",\"runtime\":\"clickhouse\",\"port\":8123}", + "variables": "[{\"key\":\"CLICKHOUSE_DB\",\"label\":\"Database Name\",\"default\":\"app\",\"required\":false,\"secret\":false},{\"key\":\"CLICKHOUSE_USER\",\"label\":\"Username\",\"default\":\"default\",\"required\":false,\"secret\":false},{\"key\":\"CLICKHOUSE_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-dragonfly", + "name": "Dragonfly Database", + "description": "Redis-compatible in-memory data store powered by Dragonfly", + "category": "database", + "logo": "https://cdn.simpleicons.org/redis", + "config": "{\"type\":\"database\",\"runtime\":\"dragonfly\",\"port\":6379}", + "variables": "[{\"key\":\"DRAGONFLY_PASSWORD\",\"label\":\"Password\",\"default\":\"\",\"required\":false,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-worker", + "name": "Background Worker", + "description": "Background job processing service", + "category": "worker", + "logo": "https://cdn.simpleicons.org/terminal", + "config": "{\"type\":\"worker\",\"runtime\":\"node\",\"build_command\":\"npm install\",\"start_command\":\"npm run worker\"}", + "variables": "[{\"key\":\"WORKER_CONCURRENCY\",\"label\":\"Concurrency\",\"default\":\"4\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-cron", + "name": "Cron Job", + "description": "Scheduled task runner", + "category": "cron", + "logo": "https://cdn.simpleicons.org/clock", + "config": "{\"type\":\"cron\",\"runtime\":\"node\",\"build_command\":\"npm install\",\"start_command\":\"npm run cron\"}", + "variables": "[{\"key\":\"CRON_SCHEDULE\",\"label\":\"Schedule\",\"default\":\"0 * * * *\",\"required\":true,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-docker", + "name": "Docker Image", + "description": "Deploy from any Docker image", + "category": "custom", + "logo": "https://cdn.simpleicons.org/docker", + "config": "{\"type\":\"web\",\"runtime\":\"docker\",\"port\":80}", + "variables": "[{\"key\":\"IMAGE\",\"label\":\"Docker Image\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"TAG\",\"label\":\"Image Tag\",\"default\":\"latest\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-nextcloud", + "name": "Nextcloud", + "description": "Self-hosted file sync, share and collaboration platform", + "category": "app", + "logo": "https://cdn.simpleicons.org/nextcloud", + "config": "{\"type\":\"web\",\"runtime\":\"nextcloud:30-apache\",\"port\":80,\"health_check\":\"/status.php\"}", + "variables": "[{\"key\":\"NEXTCLOUD_ADMIN_USER\",\"label\":\"Admin User\",\"default\":\"admin\",\"required\":false,\"secret\":false},{\"key\":\"NEXTCLOUD_ADMIN_PASSWORD\",\"label\":\"Admin Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"NEXTCLOUD_TRUSTED_DOMAINS\",\"label\":\"Trusted Domains\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-gitea", + "name": "Gitea", + "description": "Lightweight self-hosted Git service", + "category": "app", + "logo": "https://cdn.simpleicons.org/gitea", + "config": "{\"type\":\"web\",\"runtime\":\"gitea/gitea:1\",\"port\":3000,\"health_check\":\"/api/healthz\"}", + "variables": "[{\"key\":\"GITEA__server__DOMAIN\",\"label\":\"Server Domain\",\"default\":\"localhost\",\"required\":false,\"secret\":false},{\"key\":\"GITEA__server__ROOT_URL\",\"label\":\"Root URL\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-ghost", + "name": "Ghost", + "description": "Independent publishing platform for blogs and newsletters", + "category": "app", + "logo": "https://cdn.simpleicons.org/ghost", + "config": "{\"type\":\"web\",\"runtime\":\"ghost:5-alpine\",\"port\":2368,\"health_check\":\"/ghost/api/admin/site/\"}", + "variables": "[{\"key\":\"url\",\"label\":\"Public URL\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"database__client\",\"label\":\"Database Client\",\"default\":\"sqlite3\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-uptime-kuma", + "name": "Uptime Kuma", + "description": "Self-hosted monitoring tool for services and websites", + "category": "app", + "logo": "https://cdn.simpleicons.org/uptimerobot", + "config": "{\"type\":\"web\",\"runtime\":\"louislam/uptime-kuma:1\",\"port\":3001,\"health_check\":\"/\"}", + "variables": "[]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-vaultwarden", + "name": "Vaultwarden", + "description": "Lightweight Bitwarden-compatible password manager", + "category": "app", + "logo": "https://cdn.simpleicons.org/bitwarden", + "config": "{\"type\":\"web\",\"runtime\":\"vaultwarden/server:latest\",\"port\":80,\"health_check\":\"/alive\"}", + "variables": "[{\"key\":\"ADMIN_TOKEN\",\"label\":\"Admin Token\",\"default\":\"\",\"required\":false,\"secret\":true},{\"key\":\"SIGNUPS_ALLOWED\",\"label\":\"Allow Signups\",\"default\":\"false\",\"required\":false,\"secret\":false},{\"key\":\"DOMAIN\",\"label\":\"Domain URL\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-jellyfin", + "name": "Jellyfin", + "description": "Free software media system for movies, shows and music", + "category": "app", + "logo": "https://cdn.simpleicons.org/jellyfin", + "config": "{\"type\":\"web\",\"runtime\":\"jellyfin/jellyfin:latest\",\"port\":8096,\"health_check\":\"/health\"}", + "variables": "[]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-n8n", + "name": "n8n", + "description": "Workflow automation tool with hundreds of integrations", + "category": "app", + "logo": "https://cdn.simpleicons.org/n8n", + "config": "{\"type\":\"web\",\"runtime\":\"n8nio/n8n:latest\",\"port\":5678,\"health_check\":\"/healthz\"}", + "variables": "[{\"key\":\"N8N_HOST\",\"label\":\"Hostname\",\"default\":\"\",\"required\":false,\"secret\":false},{\"key\":\"N8N_ENCRYPTION_KEY\",\"label\":\"Encryption Key\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"WEBHOOK_URL\",\"label\":\"Webhook URL\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-grafana", + "name": "Grafana", + "description": "Observability dashboards for metrics, logs and traces", + "category": "app", + "logo": "https://cdn.simpleicons.org/grafana", + "config": "{\"type\":\"web\",\"runtime\":\"grafana/grafana:latest\",\"port\":3000,\"health_check\":\"/api/health\"}", + "variables": "[{\"key\":\"GF_SECURITY_ADMIN_PASSWORD\",\"label\":\"Admin Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-immich", + "name": "Immich", + "description": "Self-hosted photo and video backup", + "category": "app", + "logo": "https://cdn.simpleicons.org/immich", + "config": "{\"type\":\"web\",\"runtime\":\"ghcr.io/immich-app/immich-server:release\",\"port\":2283,\"health_check\":\"/api/server/ping\"}", + "variables": "[{\"key\":\"DB_PASSWORD\",\"label\":\"Database Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"DB_HOSTNAME\",\"label\":\"Database Host\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-meilisearch", + "name": "Meilisearch", + "description": "Fast, typo-tolerant search engine API", + "category": "app", + "logo": "https://cdn.simpleicons.org/meilisearch", + "config": "{\"type\":\"web\",\"runtime\":\"getmeili/meilisearch:v1\",\"port\":7700,\"health_check\":\"/health\"}", + "variables": "[{\"key\":\"MEILI_MASTER_KEY\",\"label\":\"Master Key\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"MEILI_ENV\",\"label\":\"Environment\",\"default\":\"production\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-plausible", + "name": "Plausible Analytics", + "description": "Lightweight, privacy-friendly web analytics", + "category": "app", + "logo": "https://cdn.simpleicons.org/plausibleanalytics", + "config": "{\"type\":\"web\",\"runtime\":\"ghcr.io/plausible/community-edition:v2\",\"port\":8000,\"health_check\":\"/api/health\"}", + "variables": "[{\"key\":\"BASE_URL\",\"label\":\"Base URL\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"SECRET_KEY_BASE\",\"label\":\"Secret Key Base\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-minio", + "name": "MinIO", + "description": "S3-compatible object storage", + "category": "app", + "logo": "https://cdn.simpleicons.org/minio", + "config": "{\"type\":\"web\",\"runtime\":\"minio/minio:latest\",\"start_command\":\"server /data --console-address \\\":9001\\\"\",\"port\":9001,\"health_check\":\"/minio/health/live\"}", + "variables": "[{\"key\":\"MINIO_ROOT_USER\",\"label\":\"Root User\",\"default\":\"minioadmin\",\"required\":true,\"secret\":false},{\"key\":\"MINIO_ROOT_PASSWORD\",\"label\":\"Root Password\",\"default\":\"\",\"required\":true,\"secret\":true}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-appwrite", + "name": "Appwrite", + "description": "Backend-as-a-service for web and mobile apps", + "category": "app", + "logo": "https://cdn.simpleicons.org/appwrite", + "config": "{\"type\":\"web\",\"runtime\":\"appwrite/appwrite:1.6\",\"port\":80,\"health_check\":\"/health\"}", + "variables": "[]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-wordpress", + "name": "WordPress", + "description": "Classic CMS powering a large share of the web", + "category": "app", + "logo": "https://cdn.simpleicons.org/wordpress", + "config": "{\"type\":\"web\",\"runtime\":\"wordpress:6-apache\",\"port\":80,\"health_check\":\"/wp-login.php\"}", + "variables": "[{\"key\":\"WORDPRESS_DB_HOST\",\"label\":\"Database Host\",\"default\":\"\",\"required\":true,\"secret\":false},{\"key\":\"WORDPRESS_DB_USER\",\"label\":\"Database User\",\"default\":\"wordpress\",\"required\":true,\"secret\":false},{\"key\":\"WORDPRESS_DB_PASSWORD\",\"label\":\"Database Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"WORDPRESS_DB_NAME\",\"label\":\"Database Name\",\"default\":\"wordpress\",\"required\":true,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-paperless", + "name": "Paperless-ngx", + "description": "Document management system with OCR", + "category": "app", + "logo": "https://cdn.simpleicons.org/paperlessngx", + "config": "{\"type\":\"web\",\"runtime\":\"ghcr.io/paperless-ngx/paperless-ngx:latest\",\"port\":8000,\"health_check\":\"/api/\"}", + "variables": "[{\"key\":\"PAPERLESS_ADMIN_PASSWORD\",\"label\":\"Admin Password\",\"default\":\"\",\"required\":true,\"secret\":true},{\"key\":\"PAPERLESS_URL\",\"label\":\"Public URL\",\"default\":\"\",\"required\":false,\"secret\":false}]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + }, + { + "id": "tpl-ntfy", + "name": "ntfy", + "description": "Push notification service over HTTP", + "category": "app", + "logo": "https://cdn.simpleicons.org/ntfy", + "config": "{\"type\":\"web\",\"runtime\":\"binwiederhier/ntfy:latest\",\"start_command\":\"serve\",\"port\":80,\"health_check\":\"/v1/health\"}", + "variables": "[]", + "is_official": true, + "is_public": false, + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z" + } + ] +}