diff --git a/ROADMAP2.md b/ROADMAP2.md
index 645d02a..c81e6b8 100644
--- a/ROADMAP2.md
+++ b/ROADMAP2.md
@@ -537,8 +537,12 @@ and wired refs, one click or one CLI call.
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
webhook table tests, Playwright e2e golden path.
-- [ ] **First-run wizard** — admin → GitHub App (manifest flow from
- Phase G makes this self-serve) → Tunnel → done.
+- [x] **First-run wizard** — public `GET /setup/status` + authed
+ `POST /setup/complete`; `/setup` page steps admin account → GitHub App
+ manifest flow (callback returns to the wizard via a session marker) →
+ optional Cloudflare tunnel token → done. Shell redirects signed-in
+ users to `/setup` while `needs_setup`; installs with existing projects
+ auto-complete so nobody gets nagged retroactively.
- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release.
## 11. Deliberately not imported from dflow
diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go
index dab0f46..35b5ecf 100644
--- a/app/backend/internal/api/routes.go
+++ b/app/backend/internal/api/routes.go
@@ -286,6 +286,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage)
public.GET("/branding", handleGetBranding)
+ public.GET("/setup/status", handleSetupStatus)
// Invite accept flow — validates the link, then registers
// even while public signup is closed.
@@ -355,6 +356,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
{
authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile)
+ authed.POST("/setup/complete", handleSetupComplete)
// Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens)
diff --git a/app/backend/internal/api/setup.go b/app/backend/internal/api/setup.go
new file mode 100644
index 0000000..e259397
--- /dev/null
+++ b/app/backend/internal/api/setup.go
@@ -0,0 +1,78 @@
+package api
+
+// First-run setup wizard state. GET /setup/status is public so the web
+// shell can redirect fresh installs before a session exists; the flag
+// lives in app_settings so it survives restarts.
+
+import (
+ "net/http"
+ "strings"
+
+ "containr/internal/database"
+
+ "github.com/gin-gonic/gin"
+)
+
+const settingSetupCompleted = "setup_completed"
+
+// setupComplete resolves the flag. Installs that predate the wizard
+// (users + projects already exist) auto-complete — nobody who has been
+// running Containr for months should get a welcome screen.
+func setupComplete(db *database.DB, hasUsers bool) bool {
+ if strings.EqualFold(settingValue(db, settingSetupCompleted, "", ""), "true") {
+ return true
+ }
+ if !hasUsers {
+ return false
+ }
+ var projects int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
+ return true
+ }
+ return false
+}
+
+// GET /api/v1/setup/status — public: fresh installs hit it before any
+// session exists.
+func handleSetupStatus(c *gin.Context) {
+ db, ok := c.MustGet("db").(*database.DB)
+ if !ok {
+ respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
+ return
+ }
+ count, err := countLocalUsers(db)
+ if err != nil {
+ respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to inspect setup state")
+ return
+ }
+ hasUsers := count > 0
+ completed := setupComplete(db, hasUsers)
+ tunnelToken, _ := resolvedTunnelToken(db)
+ c.JSON(http.StatusOK, gin.H{
+ "has_users": hasUsers,
+ "setup_completed": completed,
+ "needs_setup": !completed,
+ "has_github_app": githubAppConfigured(db),
+ "has_tunnel": tunnelToken != "",
+ "signup_enabled": signupEnabled(db),
+ })
+}
+
+// POST /api/v1/setup/complete — marks the wizard done. Open to any
+// authenticated user: the first account is the owner, and the flag only
+// suppresses a banner.
+func handleSetupComplete(c *gin.Context) {
+ if _, ok := requireAuthenticatedUserID(c); !ok {
+ return
+ }
+ db, ok := c.MustGet("db").(*database.DB)
+ if !ok {
+ respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available")
+ return
+ }
+ if err := setSetting(db, settingSetupCompleted, "true", false); err != nil {
+ respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to save setup state")
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "Setup completed"})
+}
diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx
index b2fff03..7930e2e 100644
--- a/app/frontend/src/app/App.tsx
+++ b/app/frontend/src/app/App.tsx
@@ -28,6 +28,7 @@ import {
} from '@/features/secondary/pages';
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
+import { SetupWizardPage } from '@/features/setup/SetupWizardPage';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client';
@@ -138,6 +139,7 @@ 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/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx
index c76fa28..35e0a62 100644
--- a/app/frontend/src/app/layout/PlatformShell.tsx
+++ b/app/frontend/src/app/layout/PlatformShell.tsx
@@ -1,4 +1,4 @@
-import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
+import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
@@ -36,6 +36,7 @@ import {
getCurrentUserProfile,
getUpgradeStatus,
pingServer,
+ getSetupStatus,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
@@ -185,6 +186,13 @@ export function PlatformShell() {
enabled: !isDemoMode && signedIn,
refetchInterval: 30_000,
});
+ // Fresh installs get routed through the setup wizard until the flag lands.
+ const setupQuery = useQuery({
+ queryKey: ['setup-status'],
+ queryFn: getSetupStatus,
+ enabled: !isDemoMode && signedIn,
+ staleTime: 60_000,
+ });
const markReadMutation = useMutation({
mutationFn: (id: string) => markNotificationRead(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }),
@@ -247,6 +255,10 @@ export function PlatformShell() {
);
+ if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) {
+ return ;
+ }
+
return (
diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx
index 8a68467..1bbab92 100644
--- a/app/frontend/src/features/secondary/pages.tsx
+++ b/app/frontend/src/features/secondary/pages.tsx
@@ -1253,7 +1253,11 @@ export function GitHubAppCallbackPage() {
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
- navigate('/settings', { replace: true });
+ // The setup wizard stashes a return marker before submitting the
+ // manifest form — resume there instead of the settings page.
+ const inSetup = sessionStorage.getItem('containr.setup_return') === '1';
+ sessionStorage.removeItem('containr.setup_return');
+ navigate(inSetup ? '/setup' : '/settings', { replace: true });
},
});
const firedRef = useRef(false);
diff --git a/app/frontend/src/features/setup/SetupWizardPage.test.tsx b/app/frontend/src/features/setup/SetupWizardPage.test.tsx
new file mode 100644
index 0000000..2291e20
--- /dev/null
+++ b/app/frontend/src/features/setup/SetupWizardPage.test.tsx
@@ -0,0 +1,92 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router-dom';
+import { SetupWizardPage } from './SetupWizardPage';
+import type { SetupStatus } from '@/lib/api-client';
+
+const mockStatus = vi.fn<() => Promise
>();
+const mockSession = vi.fn();
+
+vi.mock('@/lib/api-client', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ getSetupStatus: () => mockStatus(),
+ completeSetup: vi.fn().mockResolvedValue(undefined),
+ };
+});
+
+vi.mock('@/lib/use-auth-session', () => ({
+ useAuthSession: () => mockSession(),
+}));
+
+function renderWizard() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+const base: SetupStatus = {
+ has_users: false,
+ setup_completed: false,
+ needs_setup: true,
+ has_github_app: false,
+ has_tunnel: false,
+ signup_enabled: false,
+};
+
+describe('SetupWizardPage', () => {
+ beforeEach(() => {
+ mockSession.mockReturnValue({ data: null });
+ });
+
+ it('prompts for the admin account on a fresh install', async () => {
+ mockStatus.mockResolvedValue({ ...base });
+ renderWizard();
+ expect(await screen.findByText(/welcome to containr/i)).toBeInTheDocument();
+ expect(screen.getByText(/create admin account/i)).toBeInTheDocument();
+ });
+
+ it('offers sign-in when users exist but nobody is signed in', async () => {
+ mockStatus.mockResolvedValue({ ...base, has_users: true });
+ renderWizard();
+ expect(await screen.findByText(/sign in to continue setup/i)).toBeInTheDocument();
+ });
+
+ it('shows the git provider step for a signed-in admin without GitHub App', async () => {
+ mockStatus.mockResolvedValue({ ...base, has_users: true });
+ mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
+ renderWizard();
+ expect(await screen.findByText(/connect a git provider/i)).toBeInTheDocument();
+ expect(screen.getByText(/set up github app/i)).toBeInTheDocument();
+ });
+
+ it('shows the tunnel step when git is already wired', async () => {
+ mockStatus.mockResolvedValue({ ...base, has_users: true, has_github_app: true });
+ mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
+ renderWizard();
+ expect(
+ await screen.findByRole('heading', { name: /public access/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('lands on the finish step when everything is configured', async () => {
+ mockStatus.mockResolvedValue({
+ ...base,
+ has_users: true,
+ has_github_app: true,
+ has_tunnel: true,
+ });
+ mockSession.mockReturnValue({ data: { user: { id: 'u1' } } });
+ renderWizard();
+ expect(await screen.findByText(/enter containr/i)).toBeInTheDocument();
+ });
+});
diff --git a/app/frontend/src/features/setup/SetupWizardPage.tsx b/app/frontend/src/features/setup/SetupWizardPage.tsx
new file mode 100644
index 0000000..6f506c5
--- /dev/null
+++ b/app/frontend/src/features/setup/SetupWizardPage.tsx
@@ -0,0 +1,279 @@
+import { useMemo, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Link, Navigate, useNavigate } from 'react-router-dom';
+import { Check, ChevronRight, Github, Loader2, Rocket, ShieldCheck } from 'lucide-react';
+import { useAuthSession } from '@/lib/use-auth-session';
+import {
+ completeSetup,
+ createGitHubAppManifest,
+ getSetupStatus,
+ updatePlatformSettings,
+} from '@/lib/api-client';
+
+// Marker the GitHub App callback reads so the wizard flow resumes here
+// instead of the settings page.
+const SETUP_RETURN_KEY = 'containr.setup_return';
+
+type Step = 'account' | 'github' | 'tunnel' | 'done';
+
+export function SetupWizardPage() {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const sessionQuery = useAuthSession();
+ const statusQuery = useQuery({ queryKey: ['setup-status'], queryFn: getSetupStatus });
+ const [override, setOverride] = useState(null);
+
+ const status = statusQuery.data;
+ const signedIn = Boolean(sessionQuery.data);
+
+ const step: Step = useMemo(() => {
+ if (override) return override;
+ if (!status) return 'account';
+ if (!status.has_users || !signedIn) return 'account';
+ if (!status.has_github_app) return 'github';
+ if (!status.has_tunnel) return 'tunnel';
+ return 'done';
+ }, [override, status, signedIn]);
+
+ const completeMutation = useMutation({
+ mutationFn: completeSetup,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['setup-status'] });
+ navigate('/projects', { replace: true });
+ },
+ });
+
+ if (statusQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ // Wizard is optional once the instance is already configured.
+ if (status && !status.needs_setup && !override) {
+ return ;
+ }
+
+ const steps: { key: Step; label: string }[] = [
+ { key: 'account', label: 'Admin account' },
+ { key: 'github', label: 'Git provider' },
+ { key: 'tunnel', label: 'Public access' },
+ { key: 'done', label: 'Done' },
+ ];
+ const activeIdx = steps.findIndex((s) => s.key === step);
+
+ return (
+
+
+
+ {steps.map((s, i) => (
+
+
+ {i < activeIdx ? : i + 1}
+
+
+ {s.label}
+
+ {i < steps.length - 1 ?
: null}
+
+ ))}
+
+
+ {step === 'account' ? (
+
+ Welcome to Containr
+
+ First boot detected. Create the admin account — registration closes to the public
+ right after, so this account owns the instance.
+
+ {status?.has_users && !signedIn ? (
+
+
+ An account already exists — sign in to continue setup.
+
+
+ Sign in
+
+
+ ) : !status?.has_users ? (
+
+ Create admin account
+
+ ) : null}
+
+ ) : null}
+
+ {step === 'github' ?
setOverride('tunnel')} /> : null}
+ {step === 'tunnel' ? setOverride('done')} /> : null}
+
+ {step === 'done' ? (
+
+
+ All set
+
+ Deploy your first service from a git repo, an image, or a template — the dashboard is
+ ready.
+
+ {completeMutation.isError ? (
+
+ {completeMutation.error instanceof Error
+ ? completeMutation.error.message
+ : 'Failed to save setup state'}
+
+ ) : null}
+ completeMutation.mutate()}
+ disabled={completeMutation.isPending}
+ className="mt-5 inline-flex items-center gap-2 h-10 px-6 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50"
+ style={{ background: 'var(--accent-primary)' }}
+ >
+ {completeMutation.isPending ? (
+
+ ) : (
+
+ )}
+ Enter Containr
+
+
+ ) : null}
+
+
+ );
+}
+
+function GitHubStep({ onNext }: { onNext: () => void }) {
+ const queryClient = useQueryClient();
+ const provisionMutation = useMutation({
+ mutationFn: async () => {
+ const { url, manifest } = await createGitHubAppManifest({
+ base_url: window.location.origin,
+ });
+ sessionStorage.setItem(SETUP_RETURN_KEY, '1');
+ const form = document.createElement('form');
+ form.method = 'POST';
+ form.action = url;
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = 'manifest';
+ input.value = manifest;
+ form.appendChild(input);
+ document.body.appendChild(form);
+ form.submit();
+ },
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['setup-status'] }),
+ });
+
+ return (
+
+
+
+
Connect a git provider
+
+
+ Provision the instance GitHub App — repo push events then trigger deployments and preview
+ environments automatically. Self-hosted GitLab/Gitea can be added later in Settings.
+
+ {provisionMutation.isError ? (
+
+ {provisionMutation.error instanceof Error
+ ? provisionMutation.error.message
+ : 'Could not start GitHub App provisioning'}
+
+ ) : null}
+
+ provisionMutation.mutate()}
+ disabled={provisionMutation.isPending}
+ className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
+ style={{ background: 'var(--accent-primary)' }}
+ >
+ {provisionMutation.isPending ? : null}
+ Set up GitHub App
+
+
+ Skip for now
+
+
+
+ );
+}
+
+function TunnelStep({ onNext }: { onNext: () => void }) {
+ const queryClient = useQueryClient();
+ const [token, setToken] = useState('');
+ const saveMutation = useMutation({
+ mutationFn: () =>
+ updatePlatformSettings({ cloudflareTunnelToken: token.trim() || undefined }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['setup-status'] });
+ onNext();
+ },
+ });
+
+ return (
+
+ Public access (optional)
+
+ Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one,
+ Containr stays reachable on this network — you can add it later in Settings.
+
+ setToken(e.target.value)}
+ placeholder="eyJhIjo… tunnel token"
+ className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors"
+ />
+ {saveMutation.isError ? (
+
+ {saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'}
+
+ ) : null}
+
+ (token.trim() ? saveMutation.mutate() : onNext())}
+ disabled={saveMutation.isPending}
+ className="inline-flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50"
+ style={{ background: 'var(--accent-primary)' }}
+ >
+ {saveMutation.isPending ? : null}
+ {token.trim() ? 'Save tunnel token' : 'Continue'}
+
+
+ Skip
+
+
+
+ );
+}
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts
index 50e3d0f..4e6d672 100644
--- a/app/frontend/src/generated/api-types.ts
+++ b/app/frontend/src/generated/api-types.ts
@@ -5633,6 +5633,90 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/setup/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * First-run setup status
+ * @description Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically.
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Setup state */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ has_users?: boolean;
+ setup_completed?: boolean;
+ needs_setup?: boolean;
+ /** @description GitHub App credentials configured (manifest flow or env) */
+ has_github_app?: boolean;
+ /** @description Cloudflare tunnel token configured */
+ has_tunnel?: boolean;
+ signup_enabled?: boolean;
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/setup/complete": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Mark the setup wizard complete */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Setup completed */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MessageResponse"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/settings": {
parameters: {
query?: never;
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts
index 8cda35b..22d19dd 100644
--- a/app/frontend/src/lib/api-client.ts
+++ b/app/frontend/src/lib/api-client.ts
@@ -499,6 +499,23 @@ export async function pingServer(): Promise {
}
}
+export type SetupStatus = {
+ has_users: boolean;
+ setup_completed: boolean;
+ needs_setup: boolean;
+ has_github_app: boolean;
+ has_tunnel: boolean;
+ signup_enabled: boolean;
+};
+
+export async function getSetupStatus(): Promise {
+ return requestJson('/setup/status');
+}
+
+export async function completeSetup(): Promise {
+ await requestJson('/setup/complete', { method: 'POST', body: JSON.stringify({}) });
+}
+
export function getAgentPublicBaseUrl(): string {
return `${API_ROOT}/api/agents`;
}
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index af0bdfc..058419e 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -3705,6 +3705,50 @@ paths:
type: boolean
description: True when the owner re-opened public registration in Settings
+ /setup/status:
+ get:
+ tags:
+ - Authentication
+ summary: First-run setup status
+ description: Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically.
+ responses:
+ '200':
+ description: Setup state
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ has_users:
+ type: boolean
+ setup_completed:
+ type: boolean
+ needs_setup:
+ type: boolean
+ has_github_app:
+ type: boolean
+ description: GitHub App credentials configured (manifest flow or env)
+ has_tunnel:
+ type: boolean
+ description: Cloudflare tunnel token configured
+ signup_enabled:
+ type: boolean
+
+ /setup/complete:
+ post:
+ tags:
+ - Authentication
+ summary: Mark the setup wizard complete
+ security:
+ - bearerAuth: []
+ responses:
+ '200':
+ description: Setup completed
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MessageResponse'
+
/settings:
get:
tags: