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. -

+
+
+
+ + + +
+
+
+

A control plane, not a pile of YAML

+

Everything a deployment platform needs, running on hardware you already own. One install, one dashboard.

+
+
+
+
+
+ +
+

Visual project canvas

+

Services appear as nodes on a Railway-style canvas. Drag, group, and wire them together; connections and shared variables are inferred automatically. Start, stop, restart, or roll back with a right-click.

+
+
Service view with metrics, logs, and configuration tabs
+
+
+
+ +
+

Managed databases

+

Provision engines from the dashboard, with backups and restore built in.

+
+ PostgreSQLMySQLMariaDBMongoDBRedisDragonflyClickHouse +
+
+
+
+ +
+

Private networking

+

Every project gets an isolated Docker network. Services reach each other by name.

+
web:3000 → ${{service.KEY}}
+
+
+
+ +
+

Git-push deploys

+

Connect GitHub, GitLab, Bitbucket, or Gitea. Builds, history, and image rollback included.

+
+
+
+ +
+

HA and autoscaling

+

Failover policies, health checks, node agents, and metric-driven replica scaling.

+
+
+
+ +
+

Admin and audit

+

Approval console, user management, audit logs, and vulnerability scans behind sign-in.

+
+
+
+
+ +
+
+
+

The whole fleet on one screen

+

Logs, metrics, builds, and rollbacks without leaving the dashboard. Host telemetry included.

+
+ +
+
+ +
+
+
+

A catalog worth browsing

+

Thirty official templates plus community presets. Browse the full catalog or publish your own.

+
+
+
+ n8n + Nextcloud + Immich + Vaultwarden + Grafana + Supabase + Gitea + Uptime Kuma + Pi-hole + Jellyfin + Meilisearch + Mastodon + Home Assistant + Plausible + MinIO + Appwrite +
+
+
+
+ +
+
+
+

Open for visitors, locked for owners

+

The platform doubles as a public showcase. Approved projects are browsable anonymously; secrets never are.

+
+
+
+

Anonymous

+
    +
  • Browse approved projects
  • +
  • Template catalog and docs
  • +
  • HA status overview
  • +
+
+
+

Signed in

+
    +
  • Projects, services, databases
  • +
  • Logs, variables, builds, deploys
  • +
  • Custom templates
  • +
+
+
+

Admin

+
    +
  • Project approval and moderation
  • +
  • User flags and agent tokens
  • +
  • Platform settings
  • +
+
+
+
+
+ +
+
+

Run your own cloud.

+

One Docker host is all it takes. Free, open source, and yours to keep.

+ +
+
+ + + + + + diff --git a/site/screenshots/canvas.png b/site/screenshots/canvas.png new file mode 100644 index 0000000..578ce1e Binary files /dev/null and b/site/screenshots/canvas.png differ diff --git a/site/screenshots/dashboard.png b/site/screenshots/dashboard.png new file mode 100644 index 0000000..da4e0b2 Binary files /dev/null and b/site/screenshots/dashboard.png differ diff --git a/site/screenshots/databases.png b/site/screenshots/databases.png new file mode 100644 index 0000000..8365dd9 Binary files /dev/null and b/site/screenshots/databases.png differ diff --git a/site/screenshots/service-detail.png b/site/screenshots/service-detail.png new file mode 100644 index 0000000..b4a3aa2 Binary files /dev/null and b/site/screenshots/service-detail.png differ diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..d406723 --- /dev/null +++ b/site/style.css @@ -0,0 +1,624 @@ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('fonts/inter-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('fonts/inter-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + @font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url('fonts/jbmono-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + @font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url('fonts/jbmono-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + } + + :root { + --bg-void: #0c0d0f; + --bg-base: #0e0f12; + --surface-card: #131518; + --surface-card-hover: #1a1d22; + --surface-muted: #1a1d22; + --border-subtle: rgba(255, 255, 255, 0.06); + --border-default: rgba(255, 255, 255, 0.1); + --border-strong: rgba(255, 255, 255, 0.18); + --text-primary: #ecedf0; + --text-secondary: #8b919e; + --text-tertiary: #575d68; + --accent: #b4e34a; + --accent-dim: rgba(180, 227, 74, 0.12); + --accent-on: #131507; + --mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace; + --sans: 'Inter', system-ui, -apple-system, sans-serif; + --radius: 14px; + --radius-sm: 9px; + } + + * { margin: 0; padding: 0; box-sizing: border-box; } + html { scroll-behavior: smooth; } + body { + font-family: var(--sans); + background: var(--bg-void); + color: var(--text-secondary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; + } + body::before { + content: ''; + position: fixed; + inset: 0; + background: radial-gradient(1000px 480px at 50% -8%, rgba(180, 227, 74, 0.05), transparent 60%); + pointer-events: none; + } + ::selection { background: rgba(180, 227, 74, 0.25); color: var(--text-primary); } + + a { color: inherit; text-decoration: none; } + img { display: block; max-width: 100%; } + .wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; } + + /* ---------- nav ---------- */ + nav { + position: sticky; + top: 0; + z-index: 50; + border-bottom: 1px solid var(--border-subtle); + background: rgba(14, 15, 18, 0.82); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + .nav-inner { + display: flex; + align-items: center; + height: 60px; + gap: 28px; + } + .brand { + display: flex; + align-items: center; + gap: 9px; + font-weight: 800; + font-size: 15px; + letter-spacing: -0.02em; + color: var(--text-primary); + } + .brand img { width: 26px; height: 26px; border-radius: 7px; } + .brand em { font-style: normal; color: var(--accent); } + .nav-links { display: flex; gap: 4px; margin-left: 8px; } + .nav-links a { + font-size: 13px; + padding: 6px 12px; + border-radius: var(--radius-sm); + color: var(--text-secondary); + transition: color 0.15s, background 0.15s; + } + .nav-links a:hover { color: var(--text-primary); background: var(--surface-muted); } + .nav-right { margin-left: auto; display: flex; align-items: center; gap: 10px; } + .gh-link { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + transition: color 0.15s, border-color 0.15s; + } + .gh-link:hover { color: var(--text-primary); border-color: var(--border-strong); } + .btn { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 600; + padding: 8px 16px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + cursor: pointer; + transition: transform 0.15s, border-color 0.15s, background 0.15s; + white-space: nowrap; + } + .btn-primary { background: var(--accent); color: var(--accent-on); } + .btn-primary:hover { transform: translateY(-1px); } + .btn-ghost { border-color: var(--border-default); color: var(--text-secondary); } + .btn-ghost:hover { color: var(--text-primary); border-color: var(--border-strong); } + + /* ---------- hero ---------- */ + .hero { padding: 84px 0 0; } + .pill { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-tertiary); + border: 1px solid var(--border-subtle); + background: var(--surface-card); + padding: 6px 14px; + border-radius: 999px; + } + .pill svg { color: var(--accent); } + .hero h1 { + margin-top: 22px; + font-size: clamp(34px, 5.4vw, 58px); + font-weight: 800; + letter-spacing: -0.035em; + line-height: 1.06; + color: var(--text-primary); + max-width: 22ch; + } + .hero h1 em { font-style: normal; color: var(--accent); } + .hero .sub { + margin-top: 18px; + font-size: 15.5px; + line-height: 1.65; + max-width: 46ch; + } + .hero-cta { display: flex; gap: 12px; margin-top: 30px; flex-wrap: wrap; } + .hero-cta .btn { font-size: 14px; padding: 11px 22px; } + + .shot-frame { + margin-top: 56px; + border: 1px solid var(--border-default); + border-radius: var(--radius); + background: var(--surface-card); + overflow: hidden; + box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.7); + } + .shot-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid var(--border-subtle); + font-family: var(--mono); + font-size: 11px; + color: var(--text-tertiary); + } + .shot-bar .addr { + flex: 1; + text-align: center; + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 3px 0; + max-width: 320px; + margin: 0 auto; + } + .shot-frame img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; object-position: top; } + + /* ---------- install ---------- */ + .install { padding: 72px 0 0; } + .install-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 28px; + border: 1px solid var(--border-default); + border-radius: var(--radius); + background: var(--bg-base); + padding: 26px 30px; + flex-wrap: wrap; + } + .install-copy h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; } + .install-copy p { font-size: 13px; margin-top: 4px; } + .cmd { + display: flex; + align-items: center; + gap: 0; + font-family: var(--mono); + font-size: 12.5px; + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + overflow: hidden; + max-width: 100%; + } + .cmd code { + padding: 11px 14px; + color: var(--text-primary); + white-space: nowrap; + overflow-x: auto; + scrollbar-width: none; + } + .cmd code::-webkit-scrollbar { display: none; } + .cmd code::before { content: '$ '; color: var(--accent); } + .copy-btn { + border: none; + border-left: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-tertiary); + padding: 11px 13px; + cursor: pointer; + display: flex; + align-items: center; + transition: color 0.15s, background 0.15s; + flex-shrink: 0; + } + .copy-btn:hover { color: var(--accent); background: var(--surface-muted); } + + /* ---------- sections ---------- */ + section { padding: 96px 0 0; } + .sec-head { margin-bottom: 40px; } + .sec-head h2 { + font-size: clamp(24px, 3vw, 32px); + font-weight: 800; + letter-spacing: -0.025em; + color: var(--text-primary); + max-width: 22ch; + } + .sec-head p { margin-top: 10px; font-size: 14.5px; max-width: 56ch; } + + /* ---------- bento ---------- */ + .bento { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 14px; + } + .cell { + grid-column: span 2; + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: var(--surface-card); + padding: 22px; + transition: border-color 0.15s, transform 0.15s; + position: relative; + overflow: hidden; + } + .cell:hover { border-color: var(--border-default); transform: translateY(-2px); } + .cell.wide { grid-column: span 4; display: flex; gap: 26px; align-items: stretch; } + .cell h3 { font-size: 14.5px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; } + .cell p { font-size: 12.5px; line-height: 1.6; margin-top: 8px; color: var(--text-tertiary); } + .cell .icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--accent-dim); + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; + } + .cell.wide .icon { margin-bottom: 12px; } + .cell-media { + flex: 0 0 46%; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--border-subtle); + align-self: center; + } + .cell-media img { width: 100%; height: 100%; object-fit: cover; object-position: left top; } + .cell.tinted { background: linear-gradient(160deg, rgba(180, 227, 74, 0.09), rgba(180, 227, 74, 0.02) 55%), var(--surface-card); } + .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } + .chips span { + font-family: var(--mono); + font-size: 10.5px; + color: var(--text-secondary); + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 3px 8px; + } + .snippet { + margin-top: 14px; + font-family: var(--mono); + font-size: 11.5px; + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 10px 12px; + color: var(--text-secondary); + overflow-x: auto; + white-space: nowrap; + scrollbar-width: none; + } + .snippet b { color: var(--accent); font-weight: 500; } + + /* ---------- screenshots ---------- */ + .shot-tabs { display: flex; gap: 6px; margin-bottom: 18px; flex-wrap: wrap; } + .shot-tab { + font-family: var(--mono); + font-size: 11.5px; + color: var(--text-tertiary); + background: transparent; + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 7px 15px; + cursor: pointer; + transition: all 0.15s; + } + .shot-tab:hover { color: var(--text-secondary); border-color: var(--border-default); } + .shot-tab.on { color: var(--accent); border-color: rgba(180, 227, 74, 0.4); background: var(--accent-dim); } + .gallery img { width: 100%; border-radius: var(--radius); border: 1px solid var(--border-default); } + .gallery .cap { font-size: 12.5px; color: var(--text-tertiary); margin-top: 12px; } + + /* ---------- templates marquee ---------- */ + .marquee { + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: var(--bg-base); + padding: 18px 0; + mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); + -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); + } + .marquee-track { + display: flex; + gap: 10px; + width: max-content; + animation: scroll 46s linear infinite; + } + @keyframes scroll { to { transform: translateX(-50%); } } + .tpl { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); + border: 1px solid var(--border-subtle); + background: var(--surface-card); + border-radius: 999px; + padding: 8px 16px; + white-space: nowrap; + } + .tpl img { width: 15px; height: 15px; } + + /* ---------- access ---------- */ + .access { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0; + border-top: 1px solid var(--border-subtle); + } + .access > div { padding: 28px 28px 4px 0; } + .access > div + div { padding-left: 28px; border-left: 1px solid var(--border-subtle); } + .access h3 { + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); + } + .access ul { list-style: none; margin-top: 14px; } + .access li { font-size: 13px; padding: 5px 0; color: var(--text-secondary); } + .access li::before { content: '- '; color: var(--text-tertiary); } + + /* ---------- cta ---------- */ + .cta { text-align: center; padding: 110px 0; } + .cta h2 { + font-size: clamp(30px, 4.5vw, 46px); + font-weight: 800; + letter-spacing: -0.03em; + color: var(--text-primary); + } + .cta p { margin: 14px auto 0; font-size: 14.5px; max-width: 46ch; } + .cta .hero-cta { justify-content: center; } + + /* ---------- footer ---------- */ + footer { border-top: 1px solid var(--border-subtle); } + .foot { + display: flex; + align-items: center; + justify-content: space-between; + padding: 26px 0; + font-size: 12.5px; + color: var(--text-tertiary); + flex-wrap: wrap; + gap: 14px; + } + .foot .l { display: flex; align-items: center; gap: 8px; } + .foot img { width: 18px; height: 18px; border-radius: 5px; } + .foot nav { display: flex; gap: 20px; position: static; border: none; background: none; backdrop-filter: none; -webkit-backdrop-filter: none; } + .foot nav a:hover { color: var(--text-primary); } + + /* ---------- reveal (JS-gated: content stays visible without JS) ---------- */ + .js .reveal { opacity: 0; transform: translateY(16px); transition: opacity 0.55s ease, transform 0.55s ease; } + .js .reveal.in { opacity: 1; transform: none; } + + @media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .marquee-track { animation: none; } + .reveal { opacity: 1; transform: none; transition: none; } + .btn-primary:hover, .cell:hover { transform: none; } + } + + /* ---------- responsive ---------- */ + @media (max-width: 860px) { + .nav-links { display: none; } + .bento { grid-template-columns: repeat(2, 1fr); } + .cell, .cell.wide { grid-column: span 2; } + .cell.wide { flex-direction: column; } + .cell-media { flex: none; } + .access { grid-template-columns: 1fr; } + .access > div { padding: 22px 0; } + .access > div + div { padding-left: 0; border-left: none; border-top: 1px solid var(--border-subtle); } + section { padding: 72px 0 0; } + .hero { padding-top: 56px; } + } + @media (max-width: 520px) { + .bento { grid-template-columns: 1fr; } + .cell, .cell.wide { grid-column: span 1; } + .install-inner { padding: 20px; } + } + + /* ---------- hero canvas mockup ---------- */ + .cv { background: #0a0b0d; } + .cv-top { + display: flex; + align-items: center; + gap: 14px; + padding: 9px 16px; + border-bottom: 1px solid var(--border-subtle); + background: #101216; + } + .cv-back { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-tertiary); } + .cv-top b { font-size: 13px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; } + .cv-meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-tertiary); } + .cv-tabs { + margin-left: auto; + display: inline-flex; + gap: 2px; + background: var(--bg-void); + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 3px; + } + .cv-tabs i { font-style: normal; font-size: 10.5px; color: var(--text-tertiary); padding: 4px 11px; border-radius: 999px; } + .cv-tabs i.on { color: var(--text-primary); background: var(--surface-muted); } + .cv-active { font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.08em; color: #5ee6a0; } + .cv-cursor { font-style: normal; color: var(--accent); animation: cv-blink 1.1s steps(2, start) infinite; } + @keyframes cv-blink { to { visibility: hidden; } } + + .cv-stage { + position: relative; + height: 380px; + background-image: radial-gradient(circle, rgba(255, 255, 255, 0.055) 1px, transparent 1px); + background-size: 22px 22px; + overflow: hidden; + } + .cv-edges, .cv-edges-m { position: absolute; inset: 0; width: 100%; height: 100%; } + .cv-edges-m { display: none; } + .cv-edges path, .cv-edges-m path { + fill: none; + stroke: rgba(180, 227, 74, 0.42); + stroke-width: 2; + stroke-linecap: round; + filter: drop-shadow(0 0 4px rgba(180, 227, 74, 0.25)); + } + .cv-edges .live, .cv-edges-m .live { stroke-dasharray: 7 9; animation: cv-dash 1.1s linear infinite; } + @keyframes cv-dash { to { stroke-dashoffset: -16; } } + + .cv-node { + position: absolute; + width: 200px; + background: var(--surface-card); + border: 1px solid var(--border-subtle); + border-radius: 14px; + padding: 12px 12px 10px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.42); + transition: border-color 0.2s; + } + .cv-node:hover { border-color: var(--border-default); } + .cv-node.sel { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent), 0 16px 44px rgba(0, 0, 0, 0.55); + } + .cv-n1 { left: 3.2%; top: 12%; } + .cv-n2 { left: 3.2%; top: 50%; } + .cv-n3 { left: 35.2%; top: 33%; } + .cv-n4 { left: 70%; top: 7%; } + .cv-n5 { left: 70%; top: 44%; } + .cv-row { display: flex; align-items: center; gap: 10px; } + .cv-ic { + width: 34px; height: 34px; + border-radius: 9px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + font-weight: 800; font-size: 14px; + } + .cv-ic img { width: 18px; height: 18px; } + .cv-node h4 { font-size: 12.5px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; } + .cv-row p { font-size: 10px; color: var(--text-tertiary); margin-top: 2px; } + .cv-addr { + display: flex; align-items: center; gap: 6px; + margin-top: 10px; + padding: 5px 8px; + background: rgba(26, 29, 34, 0.55); + border: 1px solid var(--border-subtle); + border-radius: 7px; + color: var(--text-tertiary); + } + .cv-addr span { + font-family: var(--mono); font-size: 9.5px; color: var(--text-secondary); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .cv-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; } + .cv-st { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } + .cv-st.run { background: #5ee6a0; } + .cv-st.dep { background: #f2c94c; animation: cv-pulse 1.4s ease-in-out infinite; } + @keyframes cv-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + .cv-foot em { font-style: normal; font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #5ee6a0; } + .cv-foot code { + margin-left: auto; font-family: var(--mono); font-size: 9px; color: var(--text-tertiary); + max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + + .cv-pill { + position: absolute; + display: inline-flex; align-items: center; gap: 7px; + background: rgba(19, 21, 24, 0.85); + backdrop-filter: blur(12px); + border: 1px solid var(--border-default); + border-radius: 999px; + padding: 6px 12px; + font-family: var(--mono); font-size: 10px; color: var(--text-secondary); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); + } + .cv-pill.env { left: 14px; top: 14px; } + .cv-pill.env i { width: 6px; height: 6px; border-radius: 50%; background: #5ee6a0; } + .cv-pill.env svg { color: var(--text-tertiary); } + .cv-pill.zoom { left: 14px; bottom: 14px; padding: 5px 11px; gap: 9px; } + .cv-pill.zoom b { font-weight: 500; font-size: 10px; } + .cv-pill.zoom i { width: 1px; height: 12px; background: var(--border-subtle); } + .cv-pill.zoom svg { color: var(--text-tertiary); } + .cv-chip { + position: absolute; top: 14px; left: 50%; transform: translateX(-50%); + font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; + color: var(--accent); background: var(--accent-dim); + border: 1px solid rgba(180, 227, 74, 0.3); + border-radius: 999px; padding: 4px 10px; + } + .cv-minimap { + position: absolute; right: 14px; bottom: 14px; + width: 112px; height: 68px; + border: 1px solid var(--border-default); border-radius: 8px; + background: #0e1013; overflow: hidden; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); + } + .cv-minimap i { position: absolute; width: 15px; height: 10px; border-radius: 2px; background: rgba(180, 227, 74, 0.45); } + + @media (max-width: 720px) { + .cv-stage { height: 360px; } + .cv-edges { display: none; } + .cv-edges-m { display: block; } + .cv-node { width: 150px; padding: 9px 9px 8px; border-radius: 11px; } + .cv-node h4 { font-size: 11px; } + .cv-ic { width: 27px; height: 27px; border-radius: 7px; } + .cv-ic img { width: 14px; height: 14px; } + .cv-addr span { font-size: 8.5px; } + .cv-foot code { display: none; } + .cv-n1 { left: 4%; top: 14%; } + .cv-n2 { left: 4%; top: 62%; } + .cv-n3 { left: 52%; top: 36%; } + .cv-n4 { left: 52%; top: 2%; } + .cv-n5 { left: 52%; top: 68%; } + .cv-back, .cv-meta, .cv-minimap, .cv-pill.zoom { display: none; } + .cv-tabs i:not(.on) { display: none; } + } + @media (prefers-reduced-motion: reduce) { + .cv-edges .live, .cv-edges-m .live, .cv-st.dep, .cv-cursor { animation: none; } + } diff --git a/site/templates.html b/site/templates.html new file mode 100644 index 0000000..0e71af3 --- /dev/null +++ b/site/templates.html @@ -0,0 +1,608 @@ + + + + + +Template catalog - Containr + + + + + + + + + + + +
+
+

Template catalog

+

Deploy-ready service presets plus templates published by the community. Pick one, import it into your instance, deploy.

+ loading catalog + +
+ +
+
+
+
+ +
+
+
+
+
+ + + + + + + + diff --git a/site/templates.json b/site/templates.json new file mode 100644 index 0000000..b963324 --- /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 && 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 && 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": "", + "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": "", + "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/uptimekuma", + "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" + } + ] +} \ No newline at end of file