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/app/App.tsx b/app/frontend/src/app/App.tsx
index 81fe82e..5f71521 100644
--- a/app/frontend/src/app/App.tsx
+++ b/app/frontend/src/app/App.tsx
@@ -112,10 +112,12 @@ export default function App() {
} />
{/* Public browsing — approved projects, templates, docs. */}
+ {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
+ } />
+ } />
+
}>
} />
- } />
- } />
} />
} />
} />
diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx
index 72ba3d1..af48e90 100644
--- a/app/frontend/src/features/secondary/pages.tsx
+++ b/app/frontend/src/features/secondary/pages.tsx
@@ -64,6 +64,8 @@ import {
demoVulnerabilities,
getDemoServicesByProject,
} from '@/lib/demo-data';
+import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
+import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
@@ -1699,8 +1701,14 @@ export function DatabasesPage() {
onClick={() => setExpandedId(expanded ? null : (db.id ?? null))}
className="flex w-full items-center gap-3 p-4 text-left"
>
-
-
+
+
diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
index e2535c5..2f27626 100644
--- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
+++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useNavigate, useParams } from 'react-router-dom';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createDeployment,
deleteService,
@@ -37,6 +37,8 @@ import {
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
+import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
+import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { useAuthSession } from '@/lib/use-auth-session';
import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
@@ -122,6 +124,7 @@ function StatusBadge({ status }: { status: string }) {
export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
@@ -134,7 +137,10 @@ export function ServiceDetailPage() {
});
const isAdmin = Boolean(profileQuery.data?.isAdmin);
- const [activeSection, setActiveSection] = useState
('metrics');
+ const [activeSection, setActiveSection] = useState(() => {
+ const requested = searchParams.get('section');
+ return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics';
+ });
const [logTail, setLogTail] = useState('100');
const [varDrafts, setVarDrafts] = useState(null);
const [bulkOpen, setBulkOpen] = useState(false);
@@ -382,7 +388,7 @@ export function ServiceDetailPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
- navigate(`/projects/${projectId}`);
+ navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`);
},
});
@@ -590,18 +596,17 @@ export function ServiceDetailPage() {
{/* Project Header - self.html exact match */}
-
diff --git a/app/frontend/src/features/templates/pages/TemplatesPage.tsx b/app/frontend/src/features/templates/pages/TemplatesPage.tsx
index 81788ae..2ca4835 100644
--- a/app/frontend/src/features/templates/pages/TemplatesPage.tsx
+++ b/app/frontend/src/features/templates/pages/TemplatesPage.tsx
@@ -125,6 +125,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('');
@@ -346,6 +347,7 @@ export function TemplatesPage() {
const openCreateEditor = () => {
setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON);
+ setEditorIsPublic(false);
setEditorError(null);
setEditorOpen(true);
};
@@ -378,6 +380,7 @@ export function TemplatesPage() {
2,
),
);
+ setEditorIsPublic(template.isPublic);
setEditorError(null);
setEditorOpen(true);
};
@@ -402,6 +405,7 @@ export function TemplatesPage() {
logo: body.logo ?? '',
config: body.config as Record,
variables: Array.isArray(body.variables) ? body.variables : [],
+ isPublic: editorIsPublic,
});
};
@@ -571,6 +575,11 @@ export function TemplatesPage() {
Mine
) : null}
+ {!template.isOfficial && template.isPublic ? (
+
+ Public
+
+ ) : null}
)}
+ {!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
+
+ Public
+
+ )}
{selectedDetail.template.description}
@@ -921,9 +935,15 @@ export function TemplatesPage() {
) : null}
-
- Saved as a user template — only you (and admins) can edit or delete it.
-
+