diff --git a/cli/.gitignore b/cli/.gitignore
new file mode 100644
index 0000000..44d646d
--- /dev/null
+++ b/cli/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist/
diff --git a/cli/.mise.toml b/cli/.mise.toml
new file mode 100644
index 0000000..a94d1ed
--- /dev/null
+++ b/cli/.mise.toml
@@ -0,0 +1,2 @@
+[tools]
+bun = "latest"
diff --git a/cli/bun.lock b/cli/bun.lock
new file mode 100644
index 0000000..4b10c3f
--- /dev/null
+++ b/cli/bun.lock
@@ -0,0 +1,91 @@
+{
+  "lockfileVersion": 1,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "techulus-cli",
+      "dependencies": {
+        "yaml": "^2.8.2",
+        "zod": "^4.3.5",
+      },
+      "devDependencies": {
+        "@types/node": "^22.17.0",
+        "tsx": "^4.19.2",
+        "typescript": "^5.9.2",
+      },
+    },
+  },
+  "packages": {
+    "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
+
+    "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
+
+    "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
+
+    "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
+
+    "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
+
+    "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
+
+    "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
+
+    "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
+
+    "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
+
+    "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
+
+    "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
+
+    "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
+
+    "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
+
+    "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
+
+    "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
+
+    "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
+
+    "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
+
+    "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
+
+    "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
+
+    "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
+
+    "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
+
+    "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
+
+    "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
+
+    "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
+
+    "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
+
+    "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
+
+    "@types/node": ["@types/node@22.19.16", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-K6csxIjY+9RoDxdP6/wzaJzXaCf4znBz0/y0rrQDsbqmzQ5QFsOjubbsYWZhj6ZCgz3mjlyDZS+EJkhA9jWl9Q=="],
+
+    "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
+
+    "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+    "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
+
+    "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
+    "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "0.27.7", "get-tsconfig": "4.13.7" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
+
+    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+    "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+    "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+
+    "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+  }
+}
diff --git a/cli/package.json b/cli/package.json
new file mode 100644
index 0000000..1b8a94f
--- /dev/null
+++ b/cli/package.json
@@ -0,0 +1,24 @@
+{
+	"name": "techulus-cli",
+	"version": "0.1.0",
+	"private": true,
+	"type": "module",
+"scripts": {
+		"dev": "node --import tsx src/main.ts",
+		"build": "bun build src/main.ts --compile --outfile dist/tcloud",
+		"build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/tcloud-linux-x64",
+		"build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/tcloud-linux-arm64",
+		"build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/tcloud-darwin-x64",
+		"build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/tcloud-darwin-arm64",
+		"typecheck": "tsc --noEmit"
+	},
+	"dependencies": {
+		"yaml": "^2.8.2",
+		"zod": "^4.3.5"
+	},
+	"devDependencies": {
+		"@types/node": "^22.17.0",
+		"tsx": "^4.19.2",
+		"typescript": "^5.9.2"
+	}
+}
diff --git a/cli/src/config.ts b/cli/src/config.ts
new file mode 100644
index 0000000..c4b5c15
--- /dev/null
+++ b/cli/src/config.ts
@@ -0,0 +1,64 @@
+import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+export type CliConfig = {
+	host: string;
+	apiKey: string;
+	keyId?: string;
+	keyName?: string | null;
+	user?: {
+		id: string;
+		email: string;
+		name: string;
+	};
+};
+
+function getConfigRoot() {
+	if (process.env.XDG_CONFIG_HOME) {
+		return process.env.XDG_CONFIG_HOME;
+	}
+
+	if (process.platform === "darwin") {
+		return path.join(os.homedir(), "Library", "Application Support");
+	}
+
+	if (process.platform === "win32" && process.env.APPDATA) {
+		return process.env.APPDATA;
+	}
+
+	return path.join(os.homedir(), ".config");
+}
+
+export function getConfigDir() {
+	return path.join(getConfigRoot(), "techulus-cloud-cli");
+}
+
+export function getConfigPath() {
+	return path.join(getConfigDir(), "config.json");
+}
+
+export async function readConfig(): Promise<CliConfig | null> {
+	try {
+		const contents = await readFile(getConfigPath(), "utf8");
+		return JSON.parse(contents) as CliConfig;
+	} catch {
+		return null;
+	}
+}
+
+export async function writeConfig(config: CliConfig) {
+	const dir = getConfigDir();
+	const file = getConfigPath();
+
+	await mkdir(dir, { recursive: true, mode: 0o700 });
+	await writeFile(file, JSON.stringify(config, null, 2), {
+		encoding: "utf8",
+		mode: 0o600,
+	});
+	await chmod(file, 0o600);
+}
+
+export async function deleteConfig() {
+	await rm(getConfigPath(), { force: true });
+}
diff --git a/cli/src/main.ts b/cli/src/main.ts
new file mode 100644
index 0000000..db36e98
--- /dev/null
+++ b/cli/src/main.ts
@@ -0,0 +1,615 @@
+import { access, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { constants as fsConstants } from "node:fs";
+import { stdin as input, stdout as output } from "node:process";
+import { createInterface } from "node:readline/promises";
+import { deleteConfig, readConfig, writeConfig } from "./config.js";
+import {
+	loadManifest,
+	slugify,
+	stringifyManifest,
+	type TechulusManifest,
+} from "./manifest.js";
+
+const CLI_VERSION = "0.1.0";
+const CLI_CLIENT_ID = "techulus-cli";
+
+type JsonRequestOptions = {
+	method?: string;
+	headers?: Record<string, string>;
+	body?: unknown;
+};
+
+type LinkServiceTarget = {
+	id: string;
+	name: string;
+	project: string;
+	environment: string;
+	linkSupported: boolean;
+	unsupportedReason: string | null;
+};
+
+type LinkEnvironmentTarget = {
+	id: string;
+	name: string;
+	services: LinkServiceTarget[];
+};
+
+type LinkProjectTarget = {
+	id: string;
+	name: string;
+	slug: string;
+	environments: LinkEnvironmentTarget[];
+};
+
+function normalizeHost(host: string) {
+	const trimmed = host.trim().replace(/\/$/, "");
+	if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
+		return `https://${trimmed}`;
+	}
+
+	return trimmed;
+}
+
+async function requestJson<T>(url: string, options: JsonRequestOptions = {}) {
+	const response = await fetch(url, {
+		method: options.method ?? "GET",
+		headers: {
+			"content-type": "application/json",
+			...(options.headers ?? {}),
+		},
+		body: options.body === undefined ? undefined : JSON.stringify(options.body),
+	});
+
+	const text = await response.text();
+	const data = text ? (JSON.parse(text) as T | { error?: string }) : null;
+
+	if (!response.ok) {
+		const message =
+			data && typeof data === "object" && "error" in data && data.error
+				? data.error
+				: `Request failed with ${response.status}`;
+		throw new Error(message);
+	}
+
+	return data as T;
+}
+
+async function sleep(ms: number) {
+	return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function parseOption(args: string[], name: string) {
+	const index = args.indexOf(name);
+	if (index === -1) {
+		return null;
+	}
+
+	const value = args[index + 1];
+	if (!value || value.startsWith("--")) {
+		throw new Error(`Missing value for ${name}`);
+	}
+
+	return value;
+}
+
+function printUsage() {
+	console.log(`Usage:
+  tcloud auth login --host <url>
+  tcloud auth logout
+  tcloud auth whoami
+  tcloud init
+  tcloud link [--force]
+  tcloud apply
+  tcloud deploy
+  tcloud status`);
+}
+
+async function pathExists(filePath: string) {
+	try {
+		await access(filePath, fsConstants.F_OK);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function countSupportedServices(projects: LinkProjectTarget[]) {
+	return projects.reduce(
+		(total, project) =>
+			total +
+			project.environments.reduce(
+				(environmentTotal, environment) =>
+					environmentTotal +
+					environment.services.filter((service) => service.linkSupported).length,
+				0,
+			),
+		0,
+	);
+}
+
+async function selectFromList<T>(
+	title: string,
+	items: T[],
+	renderItem: (item: T, index: number) => string,
+	getDisabledReason?: (item: T) => string | null,
+) {
+	if (items.length === 0) {
+		throw new Error(`No options available for "${title}"`);
+	}
+
+	if (!process.stdin.isTTY || !process.stdout.isTTY) {
+		throw new Error("tcloud link requires an interactive terminal.");
+	}
+
+	const rl = createInterface({ input, output });
+
+	try {
+		while (true) {
+			console.log(`\n${title}`);
+			for (const [index, item] of items.entries()) {
+				console.log(`  ${index + 1}. ${renderItem(item, index)}`);
+			}
+
+			const answer = (await rl.question("> ")).trim();
+			const choice = Number.parseInt(answer, 10);
+
+			if (!Number.isInteger(choice) || choice < 1 || choice > items.length) {
+				console.log("Enter the number of the option you want.");
+				continue;
+			}
+
+			const selected = items[choice - 1];
+			const disabledReason = getDisabledReason?.(selected) ?? null;
+
+			if (disabledReason) {
+				console.log(disabledReason);
+				continue;
+			}
+
+			return selected;
+		}
+	} finally {
+		rl.close();
+	}
+}
+
+async function ensureManifest(cwd: string) {
+	try {
+		return await loadManifest(cwd);
+	} catch (error) {
+		if (error instanceof Error && "code" in error && error.code === "ENOENT") {
+			throw new Error(
+				"No techulus.yml found in the current directory. Run `tcloud init` to create one.",
+			);
+		}
+		throw new Error(
+			error instanceof Error
+				? `Invalid techulus.yml: ${error.message}`
+				: "Failed to load techulus.yml",
+		);
+	}
+}
+
+function authHeaders(apiKey: string) {
+	return {
+		"x-api-key": apiKey,
+	};
+}
+
+async function requireConfig() {
+	const config = await readConfig();
+	if (!config) {
+		throw new Error("Not logged in. Run `tcloud auth login --host <url>` first.");
+	}
+
+	return config;
+}
+
+async function commandAuthLogin(args: string[]) {
+	const existingConfig = await readConfig();
+	const rawHost = parseOption(args, "--host") ?? existingConfig?.host;
+
+	if (!rawHost) {
+		throw new Error("Missing --host");
+	}
+
+	const host = normalizeHost(rawHost);
+
+	const deviceCode = await requestJson<{
+		device_code: string;
+		user_code: string;
+		verification_uri: string;
+		verification_uri_complete: string;
+		expires_in: number;
+		interval: number;
+	}>(`${host}/api/auth/device/code`, {
+		method: "POST",
+		body: {
+			client_id: CLI_CLIENT_ID,
+			scope: "cli",
+		},
+	});
+
+	console.log(`Visit ${deviceCode.verification_uri}`);
+	console.log(`Enter code: ${deviceCode.user_code}`);
+	console.log("Open the verification URL in your browser to continue.");
+
+	let accessToken = "";
+	let intervalMs = deviceCode.interval * 1000;
+
+	while (!accessToken) {
+		await sleep(intervalMs);
+
+		const response = await fetch(`${host}/api/auth/device/token`, {
+			method: "POST",
+			headers: {
+				"content-type": "application/json",
+			},
+			body: JSON.stringify({
+				grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+				device_code: deviceCode.device_code,
+				client_id: CLI_CLIENT_ID,
+			}),
+		});
+
+		const data = (await response.json()) as
+			| {
+					access_token: string;
+			  }
+			| {
+					error: string;
+					error_description?: string;
+			  };
+
+		if (response.ok && "access_token" in data) {
+			accessToken = data.access_token;
+			break;
+		}
+
+		if (!("error" in data)) {
+			throw new Error("Unexpected response from device token endpoint");
+		}
+
+		switch (data.error) {
+			case "authorization_pending":
+				process.stdout.write(".");
+				break;
+			case "slow_down":
+				intervalMs += 5000;
+				break;
+			case "access_denied":
+				throw new Error(data.error_description || "Device authorization was denied");
+			case "expired_token":
+				throw new Error(data.error_description || "Device authorization expired");
+			default:
+				throw new Error(data.error_description || data.error);
+		}
+	}
+
+	console.log("\nDevice login approved. Creating a CLI API key...");
+
+	const machineName = os.hostname();
+	const platform = `${process.platform}/${process.arch}`;
+	const exchange = await requestJson<{
+		apiKey: string;
+		keyId: string;
+		name: string | null;
+		user: { id: string; email: string; name: string };
+	}>(`${host}/api/v1/cli/auth/exchange`, {
+		method: "POST",
+		headers: {
+			authorization: `Bearer ${accessToken}`,
+		},
+		body: {
+			machineName,
+			platform,
+			cliVersion: CLI_VERSION,
+		},
+	});
+
+	await writeConfig({
+		host,
+		apiKey: exchange.apiKey,
+		keyId: exchange.keyId,
+		keyName: exchange.name,
+		user: exchange.user,
+	});
+
+	console.log(`Signed in as ${exchange.user.email}`);
+}
+
+async function commandAuthLogout() {
+	await deleteConfig();
+	console.log("Signed out.");
+}
+
+async function commandAuthWhoAmI() {
+	const config = await requireConfig();
+	const whoami = await requestJson<{
+		user: { id: string; email: string; name: string };
+	}>(`${config.host}/api/v1/cli/auth/whoami`, {
+		headers: authHeaders(config.apiKey),
+	});
+
+	console.log(`Signed in as ${whoami.user.email}`);
+	console.log(`Name: ${whoami.user.name}`);
+	console.log(`Host: ${config.host}`);
+}
+
+async function commandInit(cwd: string) {
+	const manifestPath = path.join(cwd, "techulus.yml");
+	try {
+		await access(manifestPath, fsConstants.F_OK);
+		throw new Error("techulus.yml already exists");
+	} catch (error) {
+		if (error instanceof Error && error.message === "techulus.yml already exists") {
+			throw error;
+		}
+	}
+
+	const folderName = slugify(path.basename(cwd)) || "my-service";
+	const manifest = `apiVersion: v1
+project: ${folderName}
+environment: production
+service:
+  name: ${folderName}
+  source:
+    type: image
+    image: nginx:latest
+  replicas:
+    count: 1
+  ports:
+    - port: 80
+      public: false
+`;
+
+	await writeFile(manifestPath, manifest, "utf8");
+	console.log(`Created ${manifestPath}`);
+}
+
+async function commandLink(cwd: string, args: string[]) {
+	const config = await requireConfig();
+	const manifestPath = path.join(cwd, "techulus.yml");
+	const force = args.includes("--force");
+
+	if ((await pathExists(manifestPath)) && !force) {
+		throw new Error(
+			"techulus.yml already exists. Run `tcloud link --force` to replace it.",
+		);
+	}
+
+	const targets = await requestJson<{ projects: LinkProjectTarget[] }>(
+		`${config.host}/api/v1/manifest/link-targets`,
+		{
+			headers: authHeaders(config.apiKey),
+		},
+	);
+
+	if (countSupportedServices(targets.projects) === 0) {
+		throw new Error("No linkable services were found in your account.");
+	}
+
+	const projectChoices = targets.projects.filter(
+		(project) =>
+			project.environments.some((environment) => environment.services.length > 0),
+	);
+	if (projectChoices.length === 0) {
+		throw new Error("No services were found in your account.");
+	}
+
+	const project = await selectFromList(
+		"Select a project:",
+		projectChoices,
+		(project) => {
+			const serviceCount = project.environments.reduce(
+				(total, environment) => total + environment.services.length,
+				0,
+			);
+			return `${project.name} (${serviceCount} service${serviceCount === 1 ? "" : "s"})`;
+		},
+	);
+
+	const environmentChoices = project.environments.filter(
+		(environment) => environment.services.length > 0,
+	);
+	const environment = await selectFromList(
+		"Select an environment:",
+		environmentChoices,
+		(environment) => {
+			const supportedCount = environment.services.filter(
+				(service) => service.linkSupported,
+			).length;
+			return `${environment.name} (${supportedCount}/${environment.services.length} linkable)`;
+		},
+	);
+
+	const service = await selectFromList(
+		"Select a service:",
+		environment.services,
+		(service) =>
+			service.linkSupported
+				? service.name
+				: `${service.name} (unsupported: ${service.unsupportedReason})`,
+		(service) =>
+			service.linkSupported
+				? null
+				: service.unsupportedReason ?? "This service can't be linked.",
+	);
+
+	const result = await requestJson<{
+		manifest: TechulusManifest;
+		service: {
+			id: string;
+			name: string;
+			project: string;
+			environment: string;
+		};
+	}>(`${config.host}/api/v1/manifest/link`, {
+		method: "POST",
+		headers: authHeaders(config.apiKey),
+		body: {
+			serviceId: service.id,
+		},
+	});
+
+	await writeFile(manifestPath, stringifyManifest(result.manifest), "utf8");
+
+	console.log(
+		`Linked ${result.service.project}/${result.service.environment}/${result.service.name}`,
+	);
+	console.log(`Wrote ${manifestPath}`);
+	console.log("Next: run `tcloud status` or `tcloud apply`.");
+}
+
+function printApplyResult(result: {
+	action: "created" | "updated" | "noop";
+	serviceId: string;
+	changes: Array<{ field: string; from: string; to: string }>;
+}) {
+	console.log(`Action: ${result.action}`);
+	console.log(`Service ID: ${result.serviceId}`);
+
+	if (result.changes.length === 0) {
+		console.log("No changes.");
+		return;
+	}
+
+	console.log("Changes:");
+	for (const change of result.changes) {
+		console.log(`- ${change.field}: ${change.from} -> ${change.to}`);
+	}
+}
+
+async function commandApply(cwd: string) {
+	const config = await requireConfig();
+	const { manifest } = await ensureManifest(cwd);
+	const result = await requestJson<{
+		action: "created" | "updated" | "noop";
+		serviceId: string;
+		changes: Array<{ field: string; from: string; to: string }>;
+	}>(`${config.host}/api/v1/manifest/apply`, {
+		method: "POST",
+		headers: authHeaders(config.apiKey),
+		body: manifest,
+	});
+
+	printApplyResult(result);
+}
+
+async function commandDeploy(cwd: string) {
+	const config = await requireConfig();
+	const { manifest } = await ensureManifest(cwd);
+	const result = await requestJson<{
+		serviceId: string;
+		rolloutId: string | null;
+		status: string;
+	}>(`${config.host}/api/v1/manifest/deploy`, {
+		method: "POST",
+		headers: authHeaders(config.apiKey),
+		body: manifest,
+	});
+
+	console.log(`Service ID: ${result.serviceId}`);
+	console.log(`Status: ${result.status}`);
+	if (result.rolloutId) {
+		console.log(`Rollout ID: ${result.rolloutId}`);
+	}
+}
+
+async function commandStatus(cwd: string) {
+	const config = await requireConfig();
+	const { manifest } = await ensureManifest(cwd);
+	const params = new URLSearchParams({
+		project: manifest.project,
+		environment: manifest.environment,
+		service: manifest.service.name,
+	});
+	const status = await requestJson<{
+		service: {
+			id: string;
+			image: string;
+			hostname: string | null;
+			replicas: number;
+		};
+		latestRollout: {
+			id: string;
+			status: string;
+			currentStage: string | null;
+		} | null;
+		deployments: Array<{
+			id: string;
+			status: string;
+			serverId: string;
+		}>;
+	}>(`${config.host}/api/v1/manifest/status?${params.toString()}`, {
+		headers: authHeaders(config.apiKey),
+	});
+
+	console.log(`Service ID: ${status.service.id}`);
+	console.log(`Image: ${status.service.image}`);
+	console.log(`Hostname: ${status.service.hostname ?? "(none)"}`);
+	console.log(`Replicas: ${status.service.replicas}`);
+	if (status.latestRollout) {
+		console.log(
+			`Latest rollout: ${status.latestRollout.id} (${status.latestRollout.status}${status.latestRollout.currentStage ? `, ${status.latestRollout.currentStage}` : ""})`,
+		);
+	} else {
+		console.log("Latest rollout: none");
+	}
+	console.log(`Deployments: ${status.deployments.length}`);
+	for (const deployment of status.deployments) {
+		console.log(`- ${deployment.id}: ${deployment.status} on ${deployment.serverId}`);
+	}
+}
+
+async function main() {
+	const [command, subcommand, ...rest] = process.argv.slice(2);
+	const cwd = process.cwd();
+
+	if (!command) {
+		printUsage();
+		return;
+	}
+
+	switch (command) {
+		case "auth":
+			switch (subcommand) {
+				case "login":
+					await commandAuthLogin(rest);
+					return;
+				case "logout":
+					await commandAuthLogout();
+					return;
+				case "whoami":
+					await commandAuthWhoAmI();
+					return;
+				default:
+					printUsage();
+					return;
+			}
+		case "init":
+			await commandInit(cwd);
+			return;
+		case "link":
+			await commandLink(cwd, rest);
+			return;
+		case "apply":
+			await commandApply(cwd);
+			return;
+		case "deploy":
+			await commandDeploy(cwd);
+			return;
+		case "status":
+			await commandStatus(cwd);
+			return;
+		default:
+			printUsage();
+	}
+}
+
+main().catch((error) => {
+	console.error(error instanceof Error ? error.message : "Unknown error");
+	process.exit(1);
+});
diff --git a/cli/src/manifest.ts b/cli/src/manifest.ts
new file mode 100644
index 0000000..89cf0a2
--- /dev/null
+++ b/cli/src/manifest.ts
@@ -0,0 +1,93 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import YAML from "yaml";
+import { z } from "zod";
+
+const manifestPortSchema = z
+	.object({
+		port: z.number().int().min(1).max(65535),
+		public: z.boolean().default(false),
+		domain: z.string().trim().min(1).optional(),
+	})
+	.strict();
+
+const manifestHealthCheckSchema = z
+	.object({
+		cmd: z.string().trim().min(1),
+		interval: z.number().int().min(1).default(10),
+		timeout: z.number().int().min(1).default(5),
+		retries: z.number().int().min(1).default(3),
+		startPeriod: z.number().int().min(0).default(30),
+	})
+	.strict();
+
+const manifestResourcesSchema = z
+	.object({
+		cpuCores: z.number().min(0.1).max(64).nullable().optional(),
+		memoryMb: z.number().int().min(64).max(65536).nullable().optional(),
+	})
+	.strict()
+	.superRefine((value, ctx) => {
+		const hasCpu = value.cpuCores !== undefined && value.cpuCores !== null;
+		const hasMemory = value.memoryMb !== undefined && value.memoryMb !== null;
+
+		if (hasCpu !== hasMemory) {
+			ctx.addIssue({
+				code: z.ZodIssueCode.custom,
+				message: "resources must set both cpuCores and memoryMb together",
+			});
+		}
+	});
+
+export const techulusManifestSchema = z
+	.object({
+		apiVersion: z.literal("v1"),
+		project: z.string().trim().min(1),
+		environment: z.string().trim().min(1),
+		service: z
+			.object({
+				name: z.string().trim().min(1),
+				source: z
+					.object({
+						type: z.literal("image"),
+						image: z.string().trim().min(1),
+					})
+					.strict(),
+				hostname: z.string().trim().min(1).optional(),
+				ports: z.array(manifestPortSchema).default([]),
+				replicas: z
+					.object({
+						count: z.number().int().min(1).max(10).default(1),
+					})
+					.strict()
+					.default({ count: 1 }),
+				healthCheck: manifestHealthCheckSchema.optional(),
+				startCommand: z.string().trim().min(1).optional(),
+				resources: manifestResourcesSchema.optional(),
+			})
+			.strict(),
+	})
+	.strict();
+
+export type TechulusManifest = z.infer<typeof techulusManifestSchema>;
+
+export async function loadManifest(cwd: string) {
+	const manifestPath = path.join(cwd, "techulus.yml");
+	const raw = await readFile(manifestPath, "utf8");
+	const parsed = YAML.parse(raw);
+	return {
+		path: manifestPath,
+		manifest: techulusManifestSchema.parse(parsed),
+	};
+}
+
+export function stringifyManifest(manifest: TechulusManifest) {
+	return YAML.stringify(techulusManifestSchema.parse(manifest));
+}
+
+export function slugify(value: string) {
+	return value
+		.toLowerCase()
+		.replace(/[^a-z0-9]+/g, "-")
+		.replace(/^-|-$/g, "");
+}
diff --git a/cli/tsconfig.json b/cli/tsconfig.json
new file mode 100644
index 0000000..0005191
--- /dev/null
+++ b/cli/tsconfig.json
@@ -0,0 +1,14 @@
+{
+	"compilerOptions": {
+		"target": "ES2022",
+		"module": "NodeNext",
+		"moduleResolution": "NodeNext",
+		"strict": true,
+		"esModuleInterop": true,
+		"resolveJsonModule": true,
+		"skipLibCheck": true,
+		"noEmit": true,
+		"types": ["node"]
+	},
+	"include": ["src/**/*.ts"]
+}
diff --git a/web/app/(auth)/register/page.tsx b/web/app/(auth)/register/page.tsx
index 097d904..583f9b9 100644
--- a/web/app/(auth)/register/page.tsx
+++ b/web/app/(auth)/register/page.tsx
@@ -1,113 +1,17 @@
-"use client";
-
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import {
-	Card,
-	CardContent,
-	CardDescription,
-	CardFooter,
-	CardHeader,
-	CardTitle,
-} from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { signUp } from "@/lib/auth-client";
-
-export default function RegisterPage() {
-	const router = useRouter();
-	const [name, setName] = useState("");
-	const [email, setEmail] = useState("");
-	const [password, setPassword] = useState("");
-	const [error, setError] = useState("");
-	const [loading, setLoading] = useState(false);
-
-	async function handleSubmit(e: React.FormEvent) {
-		e.preventDefault();
-		setError("");
-		setLoading(true);
-
-		const { error } = await signUp.email({
-			name,
-			email,
-			password,
-		});
-
-		setLoading(false);
-
-		if (error) {
-			setError(error.message || "Failed to create account");
-			return;
-		}
-
-		router.push("/dashboard");
-	}
+import { Suspense } from "react";
+import { RegisterPage } from "@/components/auth/register-page";
+import { Spinner } from "@/components/ui/spinner";
 
+export default function Page() {
 	return (
-		<div className="min-h-screen flex items-center justify-center p-4">
-			<Card className="w-full max-w-md">
-				<CardHeader>
-					<CardTitle>Create Account</CardTitle>
-					<CardDescription>
-						Enter your details to create a new account
-					</CardDescription>
-				</CardHeader>
-				<form onSubmit={handleSubmit}>
-					<CardContent className="space-y-4 pb-4">
-						{error && (
-							<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
-								{error}
-							</div>
-						)}
-						<div className="space-y-2">
-							<Label htmlFor="name">Name</Label>
-							<Input
-								id="name"
-								type="text"
-								placeholder="John Doe"
-								value={name}
-								onChange={(e) => setName(e.target.value)}
-								required
-							/>
-						</div>
-						<div className="space-y-2">
-							<Label htmlFor="email">Email</Label>
-							<Input
-								id="email"
-								type="email"
-								placeholder="you@example.com"
-								value={email}
-								onChange={(e) => setEmail(e.target.value)}
-								required
-							/>
-						</div>
-						<div className="space-y-2">
-							<Label htmlFor="password">Password</Label>
-							<Input
-								id="password"
-								type="password"
-								value={password}
-								onChange={(e) => setPassword(e.target.value)}
-								required
-								minLength={8}
-							/>
-						</div>
-					</CardContent>
-					<CardFooter className="flex-col gap-4">
-						<Button type="submit" className="w-full" disabled={loading}>
-							{loading ? "Creating account..." : "Create Account"}
-						</Button>
-						<p className="text-sm text-muted-foreground">
-							Already have an account?{" "}
-							<Link href="/" className="text-primary hover:underline">
-								Sign in
-							</Link>
-						</p>
-					</CardFooter>
-				</form>
-			</Card>
-		</div>
+		<Suspense
+			fallback={
+				<div className="min-h-screen flex items-center justify-center">
+					<Spinner className="size-6" />
+				</div>
+			}
+		>
+			<RegisterPage />
+		</Suspense>
 	);
 }
diff --git a/web/app/api/v1/cli/auth/exchange/route.ts b/web/app/api/v1/cli/auth/exchange/route.ts
new file mode 100644
index 0000000..03778ee
--- /dev/null
+++ b/web/app/api/v1/cli/auth/exchange/route.ts
@@ -0,0 +1,57 @@
+export const dynamic = "force-dynamic";
+
+import { z } from "zod";
+import { auth } from "@/lib/auth";
+import { requireRequestSession } from "@/lib/api-auth";
+
+const exchangeSchema = z
+	.object({
+		machineName: z.string().trim().min(1).max(128).optional(),
+		platform: z.string().trim().min(1).max(128).optional(),
+		cliVersion: z.string().trim().min(1).max(64).optional(),
+	})
+	.strict();
+
+export async function POST(request: Request) {
+	const sessionResult = await requireRequestSession(request);
+	if (!sessionResult.ok) {
+		return sessionResult.response;
+	}
+
+	const body = await request.json().catch(() => ({}));
+	const parsed = exchangeSchema.safeParse(body);
+
+	if (!parsed.success) {
+		return Response.json(
+			{ error: parsed.error.issues[0]?.message || "Invalid request" },
+			{ status: 400 },
+		);
+	}
+
+	const metadata = {
+		creationSource: "techulus-cli",
+		machineName: parsed.data.machineName ?? null,
+		platform: parsed.data.platform ?? null,
+		cliVersion: parsed.data.cliVersion ?? null,
+		host: new URL(request.url).origin,
+	};
+
+	const name = parsed.data.machineName
+		? `CLI - ${parsed.data.machineName}`.slice(0, 32)
+		: "CLI";
+
+	const apiKey = await auth.api.createApiKey({
+		headers: request.headers,
+		body: {
+			name,
+			metadata,
+		},
+	});
+
+	return Response.json({
+		apiKey: apiKey.key,
+		keyId: apiKey.id,
+		name: apiKey.name,
+		user: sessionResult.session.user,
+	});
+}
diff --git a/web/app/api/v1/cli/auth/whoami/route.ts b/web/app/api/v1/cli/auth/whoami/route.ts
new file mode 100644
index 0000000..d7e00fe
--- /dev/null
+++ b/web/app/api/v1/cli/auth/whoami/route.ts
@@ -0,0 +1,18 @@
+export const dynamic = "force-dynamic";
+
+import { requireRequestSession } from "@/lib/api-auth";
+
+export async function GET(request: Request) {
+	const sessionResult = await requireRequestSession(request);
+	if (!sessionResult.ok) {
+		return sessionResult.response;
+	}
+
+	return Response.json({
+		user: sessionResult.session.user,
+		session: {
+			id: sessionResult.session.session.id,
+			expiresAt: sessionResult.session.session.expiresAt,
+		},
+	});
+}
diff --git a/web/app/api/v1/manifest/apply/route.ts b/web/app/api/v1/manifest/apply/route.ts
new file mode 100644
index 0000000..9d8b1b2
--- /dev/null
+++ b/web/app/api/v1/manifest/apply/route.ts
@@ -0,0 +1,32 @@
+export const dynamic = "force-dynamic";
+
+import { techulusManifestSchema } from "@/lib/cli-manifest";
+import { applyManifest } from "@/lib/cli-service";
+import { requireRequestSession } from "@/lib/api-auth";
+
+export async function POST(request: Request) {
+	const sessionResult = await requireRequestSession(request);
+	if (!sessionResult.ok) {
+		return sessionResult.response;
+	}
+
+	const body = await request.json().catch(() => null);
+	const parsed = techulusManifestSchema.safeParse(body);
+
+	if (!parsed.success) {
+		return Response.json(
+			{ error: parsed.error.issues[0]?.message || "Invalid manifest" },
+			{ status: 400 },
+		);
+	}
+
+	try {
+		const result = await applyManifest(parsed.data);
+		return Response.json(result);
+	} catch (error) {
+		return Response.json(
+			{ error: error instanceof Error ? error.message : "Failed to apply manifest" },
+			{ status: 400 },
+		);
+	}
+}
diff --git a/web/app/api/v1/manifest/deploy/route.ts b/web/app/api/v1/manifest/deploy/route.ts
new file mode 100644
index 0000000..bcf0696
--- /dev/null
+++ b/web/app/api/v1/manifest/deploy/route.ts
@@ -0,0 +1,32 @@
+export const dynamic = "force-dynamic";
+
+import { techulusManifestSchema } from "@/lib/cli-manifest";
+import { deployManifest } from "@/lib/cli-service";
+import { requireRequestSession } from "@/lib/api-auth";
+
+export async function POST(request: Request) {
+	const sessionResult = await requireRequestSession(request);
+	if (!sessionResult.ok) {
+		return sessionResult.response;
+	}
+
+	const body = await request.json().catch(() => null);
+	const parsed = techulusManifestSchema.safeParse(body);
+
+	if (!parsed.success) {
+		return Response.json(
+			{ error: parsed.error.issues[0]?.message || "Invalid manifest" },
+			{ status: 400 },
+		);
+	}
+
+	try {
+		const result = await deployManifest(parsed.data);
+		return Response.json(result);
+	} catch (error) {
+		return Response.json(
+			{ error: error instanceof Error ? error.message : "Failed to deploy manifest" },
+			{ status: 400 },
+		);
+	}
+}
diff --git a/web/app/api/v1/manifest/link-targets/route.ts b/web/app/api/v1/manifest/link-targets/route.ts
new file mode 100644
index 0000000..df4c801
--- /dev/null
+++ b/web/app/api/v1/manifest/link-targets/route.ts
@@ -0,0 +1,22 @@
+export const dynamic = "force-dynamic";
+
+import { requireRequestSession } from "@/lib/api-auth";
+import { listLinkTargets } from "@/lib/cli-service";
+
+export async function GET(request: Request) {
+	const sessionResult = await requireRequestSession(request);
+	if (!sessionResult.ok) {
+		return sessionResult.response;
+	}
+
+	try {
+		const result = await listLinkTargets();
+		return Response.json(result);
+	} catch (error) {
+		console.error("Failed to list link targets", error);
+		return Response.json(
+			{ error: "Failed to list link targets" },
+			{ status: 500 },
+		);
+	}
+}
diff --git a/web/app/api/v1/manifest/link/route.ts b/web/app/api/v1/manifest/link/route.ts
new file mode 100644
index 0000000..e89245a
--- /dev/null
+++ b/web/app/api/v1/manifest/link/route.ts
@@ -0,0 +1,36 @@
+export const dynamic = "force-dynamic";
+
+import { z } from "zod";
+import { requireRequestSession } from "@/lib/api-auth";
+import { exportManifestForLinkedService } from "@/lib/cli-service";
+
+const bodySchema = z.object({
+	serviceId: z.string().trim().min(1),
+});
+
+export async function POST(request: Request) {
+	const sessionResult = await requireRequestSession(request);
+	if (!sessionResult.ok) {
+		return sessionResult.response;
+	}
+
+	const body = await request.json().catch(() => null);
+	const parsed = bodySchema.safeParse(body);
+
+	if (!parsed.success) {
+		return Response.json(
+			{ error: parsed.error.issues[0]?.message || "Invalid request" },
+			{ status: 400 },
+		);
+	}
+
+	try {
+		const result = await exportManifestForLinkedService(parsed.data.serviceId);
+		return Response.json(result);
+	} catch (error) {
+		return Response.json(
+			{ error: error instanceof Error ? error.message : "Failed to link service" },
+			{ status: 400 },
+		);
+	}
+}
diff --git a/web/app/api/v1/manifest/status/route.ts b/web/app/api/v1/manifest/status/route.ts
new file mode 100644
index 0000000..5cf919d
--- /dev/null
+++ b/web/app/api/v1/manifest/status/route.ts
@@ -0,0 +1,45 @@
+export const dynamic = "force-dynamic";
+
+import { z } from "zod";
+import { getManifestStatus } from "@/lib/cli-service";
+import { requireRequestSession } from "@/lib/api-auth";
+import { slugify } from "@/lib/utils";
+
+const querySchema = z.object({
+	project: z.string().trim().min(1),
+	environment: z.string().trim().min(1),
+	service: z.string().trim().min(1),
+});
+
+export async function GET(request: Request) {
+	const sessionResult = await requireRequestSession(request);
+	if (!sessionResult.ok) {
+		return sessionResult.response;
+	}
+
+	const { searchParams } = new URL(request.url);
+	const parsed = querySchema.safeParse({
+		project: searchParams.get("project"),
+		environment: searchParams.get("environment"),
+		service: searchParams.get("service"),
+	});
+
+	if (!parsed.success) {
+		return Response.json(
+			{ error: parsed.error.issues[0]?.message || "Invalid request" },
+			{ status: 400 },
+		);
+	}
+
+	const status = await getManifestStatus({
+		project: slugify(parsed.data.project),
+		environment: parsed.data.environment,
+		service: parsed.data.service,
+	});
+
+	if (!status) {
+		return Response.json({ error: "Service not found" }, { status: 404 });
+	}
+
+	return Response.json(status);
+}
diff --git a/web/app/device/approve/page.tsx b/web/app/device/approve/page.tsx
new file mode 100644
index 0000000..12eb557
--- /dev/null
+++ b/web/app/device/approve/page.tsx
@@ -0,0 +1,17 @@
+import { Suspense } from "react";
+import { DeviceApprovalPage } from "@/components/auth/device-approval-page";
+import { Spinner } from "@/components/ui/spinner";
+
+export default function Page() {
+	return (
+		<Suspense
+			fallback={
+				<div className="min-h-screen flex items-center justify-center">
+					<Spinner className="size-6" />
+				</div>
+			}
+		>
+			<DeviceApprovalPage />
+		</Suspense>
+	);
+}
diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx
new file mode 100644
index 0000000..eb9592d
--- /dev/null
+++ b/web/app/device/page.tsx
@@ -0,0 +1,17 @@
+import { Suspense } from "react";
+import { DeviceAuthorizationPage } from "@/components/auth/device-authorization-page";
+import { Spinner } from "@/components/ui/spinner";
+
+export default function Page() {
+	return (
+		<Suspense
+			fallback={
+				<div className="min-h-screen flex items-center justify-center">
+					<Spinner className="size-6" />
+				</div>
+			}
+		>
+			<DeviceAuthorizationPage />
+		</Suspense>
+	);
+}
diff --git a/web/app/page.tsx b/web/app/page.tsx
index f7a776a..bdc6fe0 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -1,124 +1,17 @@
-"use client";
-
-import Image from "next/image";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
-import { Button } from "@/components/ui/button";
-import {
-	Card,
-	CardContent,
-	CardDescription,
-	CardFooter,
-	CardHeader,
-	CardTitle,
-} from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
+import { Suspense } from "react";
+import { SignInPage } from "@/components/auth/sign-in-page";
 import { Spinner } from "@/components/ui/spinner";
-import { signIn, useSession } from "@/lib/auth-client";
 
 export default function Page() {
-	const router = useRouter();
-	const { data: session, isPending } = useSession();
-
-	useEffect(() => {
-		if (!isPending && session) {
-			router.push("/dashboard");
-		}
-	}, [session, isPending, router]);
-
-	const [email, setEmail] = useState("");
-	const [password, setPassword] = useState("");
-	const [error, setError] = useState("");
-	const [loading, setLoading] = useState(false);
-
-	async function handleSubmit(e: React.FormEvent) {
-		e.preventDefault();
-		setError("");
-		setLoading(true);
-
-		const { error } = await signIn.email({
-			email,
-			password,
-		});
-
-		setLoading(false);
-
-		if (error) {
-			setError(error.message || "Failed to sign in");
-			return;
-		}
-
-		router.push("/dashboard");
-	}
-
-	if (isPending || session) {
-		return (
-			<div className="min-h-screen flex items-center justify-center">
-				<Spinner className="size-6" />
-			</div>
-		);
-	}
-
 	return (
-		<div className="min-h-screen flex flex-col items-center justify-center p-4">
-			<Image
-				src="/logo.png"
-				alt="Logo"
-				width={48}
-				height={48}
-				className="mb-6"
-			/>
-			<Card className="w-full max-w-md">
-				<CardHeader>
-					<CardTitle>Sign In</CardTitle>
-					<CardDescription>
-						Enter your credentials to access your account
-					</CardDescription>
-				</CardHeader>
-				<form onSubmit={handleSubmit}>
-					<CardContent className="space-y-4 pb-4">
-						{error && (
-							<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
-								{error}
-							</div>
-						)}
-						<div className="space-y-2">
-							<Label htmlFor="email">Email</Label>
-							<Input
-								id="email"
-								type="email"
-								placeholder="you@example.com"
-								value={email}
-								onChange={(e) => setEmail(e.target.value)}
-								required
-							/>
-						</div>
-						<div className="space-y-2">
-							<Label htmlFor="password">Password</Label>
-							<Input
-								id="password"
-								type="password"
-								value={password}
-								onChange={(e) => setPassword(e.target.value)}
-								required
-							/>
-						</div>
-					</CardContent>
-					<CardFooter className="flex-col gap-4">
-						<Button type="submit" className="w-full" disabled={loading}>
-							{loading ? "Signing in..." : "Sign In"}
-						</Button>
-						<p className="text-sm text-muted-foreground">
-							Don&apos;t have an account?{" "}
-							<Link href="/register" className="text-primary hover:underline">
-								Sign up
-							</Link>
-						</p>
-					</CardFooter>
-				</form>
-			</Card>
-		</div>
+		<Suspense
+			fallback={
+				<div className="min-h-screen flex items-center justify-center">
+					<Spinner className="size-6" />
+				</div>
+			}
+		>
+			<SignInPage />
+		</Suspense>
 	);
 }
diff --git a/web/components/auth/device-approval-page.tsx b/web/components/auth/device-approval-page.tsx
new file mode 100644
index 0000000..62d2659
--- /dev/null
+++ b/web/components/auth/device-approval-page.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import {
+	Card,
+	CardContent,
+	CardDescription,
+	CardFooter,
+	CardHeader,
+	CardTitle,
+} from "@/components/ui/card";
+import { Spinner } from "@/components/ui/spinner";
+import { authClient, useSession } from "@/lib/auth-client";
+
+export function DeviceApprovalPage() {
+	const router = useRouter();
+	const searchParams = useSearchParams();
+	const { data: session, isPending } = useSession();
+	const userCode = useMemo(
+		() => searchParams.get("user_code") || searchParams.get("userCode") || "",
+		[searchParams],
+	);
+	const [isProcessing, setIsProcessing] = useState(false);
+	const [error, setError] = useState("");
+	const [successMessage, setSuccessMessage] = useState("");
+
+	useEffect(() => {
+		if (isPending || session || !userCode) {
+			return;
+		}
+
+		router.replace(`/?redirect=${encodeURIComponent(`/device/approve?user_code=${userCode}`)}`);
+	}, [isPending, router, session, userCode]);
+
+	async function handleDecision(type: "approve" | "deny") {
+		if (!userCode) {
+			setError("Missing device code");
+			return;
+		}
+
+		setIsProcessing(true);
+		setError("");
+		setSuccessMessage("");
+
+		try {
+			if (type === "approve") {
+				await authClient.device.approve({
+					userCode,
+				});
+				setSuccessMessage("Device approved. You can return to the terminal.");
+			} else {
+				await authClient.device.deny({
+					userCode,
+				});
+				setSuccessMessage("Device denied. You can close this page.");
+			}
+		} catch (err) {
+			setError(err instanceof Error ? err.message : "Failed to update device request");
+		} finally {
+			setIsProcessing(false);
+		}
+	}
+
+	if (isPending) {
+		return (
+			<div className="min-h-screen flex items-center justify-center">
+				<Spinner className="size-6" />
+			</div>
+		);
+	}
+
+	return (
+		<div className="min-h-screen flex items-center justify-center p-4">
+			<Card className="w-full max-w-md">
+				<CardHeader>
+					<CardTitle>Device Authorization Request</CardTitle>
+					<CardDescription>
+						Review the pending terminal sign-in request for your account.
+					</CardDescription>
+				</CardHeader>
+				<CardContent className="space-y-4">
+					<div className="rounded-lg border bg-muted/40 p-4">
+						<p className="text-sm text-muted-foreground">Code</p>
+						<p className="font-mono text-lg">{userCode || "Unavailable"}</p>
+					</div>
+					{error ? (
+						<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
+							{error}
+						</div>
+					) : null}
+					{successMessage ? (
+						<div className="text-sm text-emerald-700 bg-emerald-500/10 p-3 rounded-lg">
+							{successMessage}
+						</div>
+					) : null}
+				</CardContent>
+				<CardFooter className="flex justify-end gap-2">
+					<Button
+						type="button"
+						variant="outline"
+						onClick={() => void handleDecision("deny")}
+						disabled={isProcessing || !!successMessage}
+					>
+						Deny
+					</Button>
+					<Button
+						type="button"
+						onClick={() => void handleDecision("approve")}
+						disabled={isProcessing || !!successMessage}
+					>
+						{isProcessing ? "Processing..." : "Approve"}
+					</Button>
+				</CardFooter>
+			</Card>
+		</div>
+	);
+}
diff --git a/web/components/auth/device-authorization-page.tsx b/web/components/auth/device-authorization-page.tsx
new file mode 100644
index 0000000..21c4c88
--- /dev/null
+++ b/web/components/auth/device-authorization-page.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import {
+	Card,
+	CardContent,
+	CardDescription,
+	CardFooter,
+	CardHeader,
+	CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { authClient } from "@/lib/auth-client";
+
+function normalizeUserCode(value: string) {
+	return value.trim().replace(/-/g, "").toUpperCase();
+}
+
+export function DeviceAuthorizationPage() {
+	const router = useRouter();
+	const searchParams = useSearchParams();
+	const initialUserCode = useMemo(
+		() => searchParams.get("user_code") || "",
+		[searchParams],
+	);
+	const [userCode, setUserCode] = useState(initialUserCode);
+	const [error, setError] = useState("");
+	const [loading, setLoading] = useState(false);
+
+	useEffect(() => {
+		setUserCode(initialUserCode);
+	}, [initialUserCode]);
+
+	async function verifyCode(code: string) {
+		const formatted = normalizeUserCode(code);
+		if (!formatted) {
+			setError("Enter the device code to continue");
+			return;
+		}
+
+		setLoading(true);
+		setError("");
+
+		try {
+			const response = await authClient.device({
+				query: { user_code: formatted },
+			});
+
+			if (response.error || !response.data) {
+				setError(response.error?.error_description || "Invalid or expired code");
+				return;
+			}
+
+			router.push(`/device/approve?user_code=${encodeURIComponent(formatted)}`);
+		} catch (err) {
+			setError(err instanceof Error ? err.message : "Invalid or expired code");
+		} finally {
+			setLoading(false);
+		}
+	}
+
+	useEffect(() => {
+		if (initialUserCode) {
+			void verifyCode(initialUserCode);
+		}
+		// initialUserCode is intentionally the only trigger here so a shared
+		// verification link can continue without another submit.
+		// eslint-disable-next-line react-hooks/exhaustive-deps
+	}, [initialUserCode]);
+
+	return (
+		<div className="min-h-screen flex items-center justify-center p-4">
+			<Card className="w-full max-w-md">
+				<CardHeader>
+					<CardTitle>Authorize Device</CardTitle>
+					<CardDescription>
+						Enter the code shown in your terminal to continue signing in.
+					</CardDescription>
+				</CardHeader>
+				<form
+					onSubmit={(event) => {
+						event.preventDefault();
+						void verifyCode(userCode);
+					}}
+				>
+					<CardContent className="space-y-4">
+						<div className="space-y-2">
+							<Label htmlFor="user-code">Device Code</Label>
+							<Input
+								id="user-code"
+								value={userCode}
+								onChange={(event) => {
+									setUserCode(event.target.value);
+									setError("");
+								}}
+								placeholder="ABCD1234"
+								autoFocus
+								autoComplete="one-time-code"
+							/>
+						</div>
+						{error ? (
+							<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
+								{error}
+							</div>
+						) : null}
+					</CardContent>
+					<CardFooter className="justify-end">
+						<Button type="submit" disabled={loading}>
+							{loading ? "Checking..." : "Continue"}
+						</Button>
+					</CardFooter>
+				</form>
+			</Card>
+		</div>
+	);
+}
diff --git a/web/components/auth/register-page.tsx b/web/components/auth/register-page.tsx
new file mode 100644
index 0000000..a2a8966
--- /dev/null
+++ b/web/components/auth/register-page.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+	Card,
+	CardContent,
+	CardDescription,
+	CardFooter,
+	CardHeader,
+	CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { signUp } from "@/lib/auth-client";
+
+export function RegisterPage() {
+	const router = useRouter();
+	const searchParams = useSearchParams();
+	const [name, setName] = useState("");
+	const [email, setEmail] = useState("");
+	const [password, setPassword] = useState("");
+	const [error, setError] = useState("");
+	const [loading, setLoading] = useState(false);
+	const redirectTo = searchParams.get("redirect") || "/dashboard";
+
+	async function handleSubmit(e: React.FormEvent) {
+		e.preventDefault();
+		setError("");
+		setLoading(true);
+
+		const { error } = await signUp.email({
+			name,
+			email,
+			password,
+		});
+
+		setLoading(false);
+
+		if (error) {
+			setError(error.message || "Failed to create account");
+			return;
+		}
+
+		router.push(redirectTo);
+	}
+
+	return (
+		<div className="min-h-screen flex items-center justify-center p-4">
+			<Card className="w-full max-w-md">
+				<CardHeader>
+					<CardTitle>Create Account</CardTitle>
+					<CardDescription>
+						Enter your details to create a new account
+					</CardDescription>
+				</CardHeader>
+				<form onSubmit={handleSubmit}>
+					<CardContent className="space-y-4 pb-4">
+						{error && (
+							<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
+								{error}
+							</div>
+						)}
+						<div className="space-y-2">
+							<Label htmlFor="name">Name</Label>
+							<Input
+								id="name"
+								type="text"
+								placeholder="John Doe"
+								value={name}
+								onChange={(e) => setName(e.target.value)}
+								required
+							/>
+						</div>
+						<div className="space-y-2">
+							<Label htmlFor="email">Email</Label>
+							<Input
+								id="email"
+								type="email"
+								placeholder="you@example.com"
+								value={email}
+								onChange={(e) => setEmail(e.target.value)}
+								required
+							/>
+						</div>
+						<div className="space-y-2">
+							<Label htmlFor="password">Password</Label>
+							<Input
+								id="password"
+								type="password"
+								value={password}
+								onChange={(e) => setPassword(e.target.value)}
+								required
+								minLength={8}
+							/>
+						</div>
+					</CardContent>
+					<CardFooter className="flex-col gap-4">
+						<Button type="submit" className="w-full" disabled={loading}>
+							{loading ? "Creating account..." : "Create Account"}
+						</Button>
+						<p className="text-sm text-muted-foreground">
+							Already have an account?{" "}
+							<Link
+								href={
+									redirectTo === "/dashboard"
+										? "/"
+										: `/?redirect=${encodeURIComponent(redirectTo)}`
+								}
+								className="text-primary hover:underline"
+							>
+								Sign in
+							</Link>
+						</p>
+					</CardFooter>
+				</form>
+			</Card>
+		</div>
+	);
+}
diff --git a/web/components/auth/sign-in-page.tsx b/web/components/auth/sign-in-page.tsx
new file mode 100644
index 0000000..1301327
--- /dev/null
+++ b/web/components/auth/sign-in-page.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+	Card,
+	CardContent,
+	CardDescription,
+	CardFooter,
+	CardHeader,
+	CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
+import { signIn, useSession } from "@/lib/auth-client";
+
+export function SignInPage() {
+	const router = useRouter();
+	const searchParams = useSearchParams();
+	const { data: session, isPending } = useSession();
+	const redirectTo = searchParams.get("redirect") || "/dashboard";
+
+	useEffect(() => {
+		if (!isPending && session) {
+			router.push(redirectTo);
+		}
+	}, [session, isPending, redirectTo, router]);
+
+	const [email, setEmail] = useState("");
+	const [password, setPassword] = useState("");
+	const [error, setError] = useState("");
+	const [loading, setLoading] = useState(false);
+
+	async function handleSubmit(e: React.FormEvent) {
+		e.preventDefault();
+		setError("");
+		setLoading(true);
+
+		const { error } = await signIn.email({
+			email,
+			password,
+		});
+
+		setLoading(false);
+
+		if (error) {
+			setError(error.message || "Failed to sign in");
+			return;
+		}
+
+		router.push(redirectTo);
+	}
+
+	if (isPending || session) {
+		return (
+			<div className="min-h-screen flex items-center justify-center">
+				<Spinner className="size-6" />
+			</div>
+		);
+	}
+
+	return (
+		<div className="min-h-screen flex flex-col items-center justify-center p-4">
+			<Image
+				src="/logo.png"
+				alt="Logo"
+				width={48}
+				height={48}
+				className="mb-6"
+			/>
+			<Card className="w-full max-w-md">
+				<CardHeader>
+					<CardTitle>Sign In</CardTitle>
+					<CardDescription>
+						Enter your credentials to access your account
+					</CardDescription>
+				</CardHeader>
+				<form onSubmit={handleSubmit}>
+					<CardContent className="space-y-4 pb-4">
+						{error && (
+							<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
+								{error}
+							</div>
+						)}
+						<div className="space-y-2">
+							<Label htmlFor="email">Email</Label>
+							<Input
+								id="email"
+								type="email"
+								placeholder="you@example.com"
+								value={email}
+								onChange={(e) => setEmail(e.target.value)}
+								required
+							/>
+						</div>
+						<div className="space-y-2">
+							<Label htmlFor="password">Password</Label>
+							<Input
+								id="password"
+								type="password"
+								value={password}
+								onChange={(e) => setPassword(e.target.value)}
+								required
+							/>
+						</div>
+					</CardContent>
+					<CardFooter className="flex-col gap-4">
+						<Button type="submit" className="w-full" disabled={loading}>
+							{loading ? "Signing in..." : "Sign In"}
+						</Button>
+						<p className="text-sm text-muted-foreground">
+							Don&apos;t have an account?{" "}
+							<Link
+								href={
+									redirectTo === "/dashboard"
+										? "/register"
+										: `/register?redirect=${encodeURIComponent(redirectTo)}`
+								}
+								className="text-primary hover:underline"
+							>
+								Sign up
+							</Link>
+						</p>
+					</CardFooter>
+				</form>
+			</Card>
+		</div>
+	);
+}
diff --git a/web/db/schema.ts b/web/db/schema.ts
index c3e60b8..e8e83bb 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -84,9 +84,68 @@ export const verification = pgTable(
 	(table) => [index("verification_identifier_idx").on(table.identifier)],
 );
 
+export const deviceCode = pgTable(
+	"deviceCode",
+	{
+		id: text("id").primaryKey(),
+		deviceCode: text("device_code").notNull(),
+		userCode: text("user_code").notNull(),
+		userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
+		expiresAt: timestamp("expires_at").notNull(),
+		status: text("status").notNull(),
+		lastPolledAt: timestamp("last_polled_at"),
+		pollingInterval: integer("polling_interval"),
+		clientId: text("client_id"),
+		scope: text("scope"),
+	},
+	(table) => [
+		index("device_code_device_code_idx").on(table.deviceCode),
+		index("device_code_user_code_idx").on(table.userCode),
+		index("device_code_user_id_idx").on(table.userId),
+	],
+);
+
+export const apikey = pgTable(
+	"apikey",
+	{
+		id: text("id").primaryKey(),
+		name: text("name"),
+		start: text("start"),
+		prefix: text("prefix"),
+		key: text("key").notNull(),
+		userId: text("user_id")
+			.notNull()
+			.references(() => user.id, { onDelete: "cascade" }),
+		refillInterval: integer("refill_interval"),
+		refillAmount: integer("refill_amount"),
+		lastRefillAt: timestamp("last_refill_at"),
+		enabled: boolean("enabled").default(true),
+		rateLimitEnabled: boolean("rate_limit_enabled").default(true),
+		rateLimitTimeWindow: integer("rate_limit_time_window"),
+		rateLimitMax: integer("rate_limit_max"),
+		requestCount: integer("request_count").default(0),
+		remaining: integer("remaining"),
+		lastRequest: timestamp("last_request"),
+		expiresAt: timestamp("expires_at"),
+		createdAt: timestamp("created_at").defaultNow().notNull(),
+		updatedAt: timestamp("updated_at")
+			.defaultNow()
+			.$onUpdate(() => /* @__PURE__ */ new Date())
+			.notNull(),
+		permissions: text("permissions"),
+		metadata: text("metadata"),
+	},
+	(table) => [
+		index("apikey_key_idx").on(table.key),
+		index("apikey_user_id_idx").on(table.userId),
+	],
+);
+
 export const userRelations = relations(user, ({ many }) => ({
 	sessions: many(session),
 	accounts: many(account),
+	apiKeys: many(apikey),
+	deviceCodes: many(deviceCode),
 }));
 
 export const sessionRelations = relations(session, ({ one }) => ({
@@ -103,6 +162,20 @@ export const accountRelations = relations(account, ({ one }) => ({
 	}),
 }));
 
+export const deviceCodeRelations = relations(deviceCode, ({ one }) => ({
+	user: one(user, {
+		fields: [deviceCode.userId],
+		references: [user.id],
+	}),
+}));
+
+export const apiKeyRelations = relations(apikey, ({ one }) => ({
+	user: one(user, {
+		fields: [apikey.userId],
+		references: [user.id],
+	}),
+}));
+
 type ServerMeta = {
 	arch?: string;
 	os?: string;
diff --git a/web/lib/api-auth.ts b/web/lib/api-auth.ts
new file mode 100644
index 0000000..2e12ff9
--- /dev/null
+++ b/web/lib/api-auth.ts
@@ -0,0 +1,23 @@
+import { auth } from "@/lib/auth";
+
+export async function getRequestSession(request: Request) {
+	return auth.api.getSession({
+		headers: request.headers,
+	});
+}
+
+export async function requireRequestSession(request: Request) {
+	const session = await getRequestSession(request);
+
+	if (!session) {
+		return {
+			ok: false as const,
+			response: Response.json({ error: "Unauthorized" }, { status: 401 }),
+		};
+	}
+
+	return {
+		ok: true as const,
+		session,
+	};
+}
diff --git a/web/lib/auth-client.ts b/web/lib/auth-client.ts
index dde6404..1357fba 100644
--- a/web/lib/auth-client.ts
+++ b/web/lib/auth-client.ts
@@ -1,5 +1,11 @@
 import { createAuthClient } from "better-auth/react";
+import {
+	apiKeyClient,
+	deviceAuthorizationClient,
+} from "better-auth/client/plugins";
 
-export const authClient = createAuthClient();
+export const authClient = createAuthClient({
+	plugins: [apiKeyClient(), deviceAuthorizationClient()],
+});
 
 export const { signIn, signUp, signOut, useSession } = authClient;
diff --git a/web/lib/auth.ts b/web/lib/auth.ts
index 9a1ad2a..36cf7a5 100644
--- a/web/lib/auth.ts
+++ b/web/lib/auth.ts
@@ -1,8 +1,11 @@
 import { betterAuth } from "better-auth";
 import { drizzleAdapter } from "better-auth/adapters/drizzle";
+import { apiKey, bearer, deviceAuthorization } from "better-auth/plugins";
 import { db } from "@/db";
 import * as schema from "@/db/schema";
 
+export const TECHULUS_CLI_CLIENT_ID = "techulus-cli";
+
 export const auth = betterAuth({
 	database: drizzleAdapter(db, {
 		provider: "pg",
@@ -12,4 +15,18 @@ export const auth = betterAuth({
 		enabled: true,
 		disableSignUp: process.env.ALLOW_SIGNUP !== "true",
 	},
+	plugins: [
+		deviceAuthorization({
+			verificationUri: "/device",
+			validateClient: async (clientId) => clientId === TECHULUS_CLI_CLIENT_ID,
+		}),
+		apiKey({
+			enableSessionForAPIKeys: true,
+			apiKeyHeaders: "x-api-key",
+			defaultPrefix: "tcl_",
+			enableMetadata: true,
+			requireName: true,
+		}),
+		bearer(),
+	],
 });
diff --git a/web/lib/cli-manifest.ts b/web/lib/cli-manifest.ts
new file mode 100644
index 0000000..00382f6
--- /dev/null
+++ b/web/lib/cli-manifest.ts
@@ -0,0 +1,99 @@
+import { z } from "zod";
+import { slugify } from "@/lib/utils";
+
+const manifestPortSchema = z
+	.object({
+		port: z.number().int().min(1).max(65535),
+		public: z.boolean().default(false),
+		domain: z.string().trim().min(1).optional(),
+	})
+	.strict()
+	.superRefine((value, ctx) => {
+		if (value.public && !value.domain) {
+			ctx.addIssue({
+				code: z.ZodIssueCode.custom,
+				path: ["domain"],
+				message: "Public HTTP ports require a domain",
+			});
+		}
+
+		if (!value.public && value.domain) {
+			ctx.addIssue({
+				code: z.ZodIssueCode.custom,
+				path: ["domain"],
+				message: "Internal ports cannot define a domain",
+			});
+		}
+	});
+
+const manifestHealthCheckSchema = z
+	.object({
+		cmd: z.string().trim().min(1),
+		interval: z.number().int().min(1).default(10),
+		timeout: z.number().int().min(1).default(5),
+		retries: z.number().int().min(1).default(3),
+		startPeriod: z.number().int().min(0).default(30),
+	})
+	.strict();
+
+const manifestResourcesSchema = z
+	.object({
+		cpuCores: z.number().min(0.1).max(64).nullable().optional(),
+		memoryMb: z.number().int().min(64).max(65536).nullable().optional(),
+	})
+	.strict()
+	.superRefine((value, ctx) => {
+		const hasCpu = value.cpuCores !== undefined && value.cpuCores !== null;
+		const hasMemory = value.memoryMb !== undefined && value.memoryMb !== null;
+
+		if (hasCpu !== hasMemory) {
+			ctx.addIssue({
+				code: z.ZodIssueCode.custom,
+				message: "Resources must set both cpuCores and memoryMb together",
+			});
+		}
+	});
+
+export const techulusManifestSchema = z
+	.object({
+		apiVersion: z.literal("v1"),
+		project: z.string().trim().min(1),
+		environment: z.string().trim().min(1),
+		service: z
+			.object({
+				name: z.string().trim().min(1),
+				source: z
+					.object({
+						type: z.literal("image"),
+						image: z.string().trim().min(1),
+					})
+					.strict(),
+				hostname: z.string().trim().min(1).optional(),
+				ports: z.array(manifestPortSchema).default([]),
+				replicas: z
+					.object({
+						count: z.number().int().min(1).max(10).default(1),
+					})
+					.strict()
+					.default({ count: 1 }),
+				healthCheck: manifestHealthCheckSchema.optional(),
+				startCommand: z.string().trim().min(1).optional(),
+				resources: manifestResourcesSchema.optional(),
+			})
+			.strict(),
+	})
+	.strict();
+
+export type TechulusManifest = z.infer<typeof techulusManifestSchema>;
+
+export function getManifestProjectSlug(manifest: TechulusManifest) {
+	return slugify(manifest.project);
+}
+
+export function getManifestEnvironmentName(manifest: TechulusManifest) {
+	return slugify(manifest.environment);
+}
+
+export function getManifestServiceName(manifest: TechulusManifest) {
+	return manifest.service.name.trim();
+}
diff --git a/web/lib/cli-service.ts b/web/lib/cli-service.ts
new file mode 100644
index 0000000..0d4e672
--- /dev/null
+++ b/web/lib/cli-service.ts
@@ -0,0 +1,993 @@
+import { and, desc, eq, ne } from "drizzle-orm";
+import { db } from "@/db";
+import {
+	deployments,
+	environments,
+	projects,
+	rollouts,
+	servicePorts,
+	serviceVolumes,
+	services,
+} from "@/db/schema";
+import {
+	techulusManifestSchema,
+	type TechulusManifest,
+} from "@/lib/cli-manifest";
+import {
+	getManifestEnvironmentName,
+	getManifestProjectSlug,
+	getManifestServiceName,
+} from "@/lib/cli-manifest";
+import { slugify } from "@/lib/utils";
+import {
+	createEnvironment,
+	createProject,
+	createService,
+	deployService,
+	updateServiceAutoPlace,
+	updateServiceConfig,
+	updateServiceReplicas,
+	updateServiceResourceLimits,
+	updateServiceStartCommand,
+	validateDockerImage,
+} from "@/actions/projects";
+
+export type ManifestChange = {
+	field: string;
+	from: string;
+	to: string;
+};
+
+export type ManifestApplyResult = {
+	project: { id: string; name: string; slug: string };
+	environment: { id: string; name: string };
+	serviceId: string;
+	action: "created" | "updated" | "noop";
+	changes: ManifestChange[];
+};
+
+export type LinkServiceTarget = {
+	id: string;
+	name: string;
+	project: string;
+	environment: string;
+	linkSupported: boolean;
+	unsupportedReason: string | null;
+};
+
+export type LinkEnvironmentTarget = {
+	id: string;
+	name: string;
+	services: LinkServiceTarget[];
+};
+
+export type LinkProjectTarget = {
+	id: string;
+	name: string;
+	slug: string;
+	environments: LinkEnvironmentTarget[];
+};
+
+export type LinkTargetsResult = {
+	projects: LinkProjectTarget[];
+};
+
+export type LinkManifestResult = {
+	manifest: TechulusManifest;
+	service: {
+		id: string;
+		name: string;
+		project: string;
+		environment: string;
+	};
+};
+
+type ManifestIdentity = {
+	project: string;
+	environment: string;
+	service: string;
+};
+
+type ServiceCompatibilityRecord = Pick<
+	typeof services.$inferSelect,
+	| "sourceType"
+	| "stateful"
+	| "autoPlace"
+	| "replicas"
+	| "resourceCpuLimit"
+	| "resourceMemoryLimitMb"
+>;
+
+type PortCompatibilityRecord = Pick<
+	typeof servicePorts.$inferSelect,
+	"protocol" | "isPublic" | "domain"
+>;
+
+type LinkValidationService = Pick<
+	typeof services.$inferSelect,
+	| "id"
+	| "name"
+	| "projectId"
+	| "environmentId"
+	| "hostname"
+	| "image"
+	| "sourceType"
+	| "replicas"
+	| "stateful"
+	| "autoPlace"
+	| "healthCheckCmd"
+	| "healthCheckInterval"
+	| "healthCheckTimeout"
+	| "healthCheckRetries"
+	| "healthCheckStartPeriod"
+	| "startCommand"
+	| "resourceCpuLimit"
+	| "resourceMemoryLimitMb"
+>;
+
+type LinkValidationPort = Pick<
+	typeof servicePorts.$inferSelect,
+	"serviceId" | "port" | "isPublic" | "domain" | "protocol"
+>;
+
+type ServiceLinkValidation = {
+	service: LinkValidationService;
+	ports: LinkValidationPort[];
+	unsupportedReason: string | null;
+};
+
+function formatPort(port: { port: number; isPublic: boolean; domain: string | null }) {
+	return port.isPublic ? `${port.port} -> ${port.domain}` : `${port.port} (internal)`;
+}
+
+function formatNullable(value: string | number | null | undefined, fallback = "(none)") {
+	if (value === null || value === undefined || value === "") {
+		return fallback;
+	}
+
+	return String(value);
+}
+
+function recordChange(
+	changes: ManifestChange[],
+	field: string,
+	from: string | number | null | undefined,
+	to: string | number | null | undefined,
+) {
+	if ((from ?? null) === (to ?? null)) {
+		return;
+	}
+
+	changes.push({
+		field,
+		from: formatNullable(from),
+		to: formatNullable(to),
+	});
+}
+
+async function findProjectByManifest(manifest: TechulusManifest) {
+	const slug = getManifestProjectSlug(manifest);
+	return findProjectBySlug(slug);
+}
+
+async function findProjectBySlug(slug: string) {
+	const [project] = await db
+		.select()
+		.from(projects)
+		.where(eq(projects.slug, slug))
+		.limit(1);
+
+	return project ?? null;
+}
+
+async function findEnvironmentByManifest(
+	projectId: string,
+	manifest: TechulusManifest,
+) {
+	const environmentName = getManifestEnvironmentName(manifest);
+	return findEnvironmentByName(projectId, environmentName);
+}
+
+async function findEnvironmentByName(projectId: string, environmentName: string) {
+	const [environment] = await db
+		.select()
+		.from(environments)
+		.where(
+			and(
+				eq(environments.projectId, projectId),
+				eq(environments.name, environmentName),
+			),
+		)
+		.limit(1);
+
+	return environment ?? null;
+}
+
+async function findServiceByManifest(
+	projectId: string,
+	environmentId: string,
+	manifest: TechulusManifest,
+) {
+	const serviceName = getManifestServiceName(manifest);
+	return findServiceByName(projectId, environmentId, serviceName);
+}
+
+async function findServiceByName(
+	projectId: string,
+	environmentId: string,
+	serviceName: string,
+) {
+	const [service] = await db
+		.select()
+		.from(services)
+		.where(
+			and(
+				eq(services.projectId, projectId),
+				eq(services.environmentId, environmentId),
+				eq(services.name, serviceName),
+			),
+		)
+		.limit(1);
+
+	return service ?? null;
+}
+
+function getUnsupportedReason(
+	service: ServiceCompatibilityRecord,
+	ports: PortCompatibilityRecord[],
+	volumeCount: number,
+) {
+	if (service.sourceType !== "image") {
+		return "CLI v1 only supports image-backed services. This service uses an unsupported source.";
+	}
+
+	if (service.stateful || volumeCount > 0) {
+		return "CLI v1 does not support stateful services or volumes. Manage this service from the web UI.";
+	}
+
+	if (!service.autoPlace) {
+		return "CLI v1 only supports auto-placement. Manage this service from the web UI.";
+	}
+
+	if (ports.some((port) => port.protocol !== "http")) {
+		return "CLI v1 only supports HTTP ports. This service has TCP or UDP ports configured.";
+	}
+
+	if (ports.some((port) => port.isPublic && !port.domain)) {
+		return "CLI v1 requires every public HTTP port to have a domain.";
+	}
+
+	if (service.replicas < 1 || service.replicas > 10) {
+		return "CLI v1 only supports replica counts between 1 and 10.";
+	}
+
+	const hasCpu = service.resourceCpuLimit !== null;
+	const hasMemory = service.resourceMemoryLimitMb !== null;
+
+	if (hasCpu !== hasMemory) {
+		return "CLI v1 requires both CPU and memory limits to be set together.";
+	}
+
+	return null;
+}
+
+async function getServiceLinkValidation(
+	serviceId: string,
+): Promise<ServiceLinkValidation | null> {
+	const [service] = await db
+		.select({
+			id: services.id,
+			name: services.name,
+			projectId: services.projectId,
+			environmentId: services.environmentId,
+			hostname: services.hostname,
+			image: services.image,
+			sourceType: services.sourceType,
+			replicas: services.replicas,
+			stateful: services.stateful,
+			autoPlace: services.autoPlace,
+			healthCheckCmd: services.healthCheckCmd,
+			healthCheckInterval: services.healthCheckInterval,
+			healthCheckTimeout: services.healthCheckTimeout,
+			healthCheckRetries: services.healthCheckRetries,
+			healthCheckStartPeriod: services.healthCheckStartPeriod,
+			startCommand: services.startCommand,
+			resourceCpuLimit: services.resourceCpuLimit,
+			resourceMemoryLimitMb: services.resourceMemoryLimitMb,
+		})
+		.from(services)
+		.where(eq(services.id, serviceId))
+		.limit(1);
+
+	if (!service) {
+		return null;
+	}
+
+	const [ports, volumes] = await Promise.all([
+		db
+			.select({
+				serviceId: servicePorts.serviceId,
+				port: servicePorts.port,
+				isPublic: servicePorts.isPublic,
+				domain: servicePorts.domain,
+				protocol: servicePorts.protocol,
+			})
+			.from(servicePorts)
+			.where(eq(servicePorts.serviceId, serviceId))
+			.orderBy(servicePorts.port),
+		db
+			.select({ id: serviceVolumes.id })
+			.from(serviceVolumes)
+			.where(eq(serviceVolumes.serviceId, serviceId)),
+	]);
+
+	return {
+		service,
+		ports,
+		unsupportedReason: getUnsupportedReason(service, ports, volumes.length),
+	};
+}
+
+async function syncHostname(
+	serviceId: string,
+	currentHostname: string | null,
+	desiredHostname: string | null,
+	changes: ManifestChange[],
+) {
+	if (currentHostname === desiredHostname) {
+		return;
+	}
+
+	if (desiredHostname) {
+		const [existing] = await db
+			.select({ id: services.id })
+			.from(services)
+			.where(
+				and(eq(services.hostname, desiredHostname), ne(services.id, serviceId)),
+			)
+			.limit(1);
+
+		if (existing) {
+			throw new Error("Hostname is already in use");
+		}
+	}
+
+	await db
+		.update(services)
+		.set({ hostname: desiredHostname })
+		.where(eq(services.id, serviceId));
+
+	recordChange(changes, "Hostname", currentHostname, desiredHostname);
+}
+
+async function syncImage(
+	serviceId: string,
+	currentImage: string,
+	desiredImage: string,
+	changes: ManifestChange[],
+) {
+	if (currentImage === desiredImage) {
+		return;
+	}
+
+	const validation = await validateDockerImage(desiredImage);
+	if (!validation.valid) {
+		throw new Error(validation.error || "Invalid image");
+	}
+
+	await updateServiceConfig(serviceId, {
+		source: { type: "image", image: desiredImage },
+	});
+
+	recordChange(changes, "Image", currentImage, desiredImage);
+}
+
+async function syncPorts(
+	serviceId: string,
+	desiredPorts: TechulusManifest["service"]["ports"],
+	changes: ManifestChange[],
+) {
+	const currentPorts = await db
+		.select()
+		.from(servicePorts)
+		.where(eq(servicePorts.serviceId, serviceId));
+
+	const currentKeys = new Map(
+		currentPorts.map((port) => [
+			`${port.port}:${port.isPublic ? "public" : "internal"}:${port.domain ?? ""}`,
+			port,
+		]),
+	);
+
+	const desiredKeys = new Map(
+		desiredPorts.map((port) => [
+			`${port.port}:${port.public ? "public" : "internal"}:${port.domain ?? ""}`,
+			port,
+		]),
+	);
+
+	const portsToRemove = currentPorts
+		.filter(
+			(port) =>
+				!desiredKeys.has(
+					`${port.port}:${port.isPublic ? "public" : "internal"}:${port.domain ?? ""}`,
+				),
+		)
+		.map((port) => port.id);
+
+	const portsToAdd = desiredPorts
+		.filter(
+			(port) =>
+				!currentKeys.has(
+					`${port.port}:${port.public ? "public" : "internal"}:${port.domain ?? ""}`,
+				),
+		)
+		.map((port) => ({
+			port: port.port,
+			isPublic: port.public,
+			domain: port.public ? port.domain ?? null : null,
+			protocol: "http" as const,
+		}));
+
+	if (portsToRemove.length === 0 && portsToAdd.length === 0) {
+		return;
+	}
+
+	await updateServiceConfig(serviceId, {
+		ports: {
+			remove: portsToRemove,
+			add: portsToAdd,
+		},
+	});
+
+	for (const port of currentPorts.filter((item) => portsToRemove.includes(item.id))) {
+		changes.push({
+			field: `Port ${port.port}`,
+			from: formatPort(port),
+			to: "(removed)",
+		});
+	}
+
+	for (const port of portsToAdd) {
+		changes.push({
+			field: `Port ${port.port}`,
+			from: "(none)",
+			to: port.isPublic ? `${port.port} -> ${port.domain}` : `${port.port} (internal)`,
+		});
+	}
+}
+
+async function syncHealthCheck(
+	serviceId: string,
+	currentService: Pick<
+		LinkValidationService,
+		| "healthCheckCmd"
+		| "healthCheckInterval"
+		| "healthCheckTimeout"
+		| "healthCheckRetries"
+		| "healthCheckStartPeriod"
+	>,
+	manifest: TechulusManifest,
+	changes: ManifestChange[],
+) {
+	const current =
+		currentService.healthCheckCmd === null
+			? null
+			: {
+					cmd: currentService.healthCheckCmd,
+					interval: currentService.healthCheckInterval ?? 10,
+					timeout: currentService.healthCheckTimeout ?? 5,
+					retries: currentService.healthCheckRetries ?? 3,
+					startPeriod: currentService.healthCheckStartPeriod ?? 30,
+				};
+
+	const desired = manifest.service.healthCheck ?? null;
+
+	if (JSON.stringify(current) === JSON.stringify(desired)) {
+		return;
+	}
+
+	await updateServiceConfig(serviceId, {
+		healthCheck: desired,
+	});
+
+	recordChange(
+		changes,
+		"Health check",
+		current?.cmd ?? null,
+		desired?.cmd ?? null,
+	);
+}
+
+async function syncStartCommand(
+	serviceId: string,
+	currentStartCommand: string | null,
+	desiredStartCommand: string | null,
+	changes: ManifestChange[],
+) {
+	if (currentStartCommand === desiredStartCommand) {
+		return;
+	}
+
+	await updateServiceStartCommand(serviceId, desiredStartCommand);
+	recordChange(
+		changes,
+		"Start command",
+		currentStartCommand,
+		desiredStartCommand,
+	);
+}
+
+async function syncResources(
+	serviceId: string,
+	currentService: Pick<
+		LinkValidationService,
+		"resourceCpuLimit" | "resourceMemoryLimitMb"
+	>,
+	manifest: TechulusManifest,
+	changes: ManifestChange[],
+) {
+	const desiredCpu = manifest.service.resources?.cpuCores ?? null;
+	const desiredMemory = manifest.service.resources?.memoryMb ?? null;
+
+	if (
+		currentService.resourceCpuLimit === desiredCpu &&
+		currentService.resourceMemoryLimitMb === desiredMemory
+	) {
+		return;
+	}
+
+	await updateServiceResourceLimits(serviceId, {
+		cpuCores: desiredCpu,
+		memoryMb: desiredMemory,
+	});
+
+	recordChange(
+		changes,
+		"CPU limit",
+		currentService.resourceCpuLimit,
+		desiredCpu,
+	);
+	recordChange(
+		changes,
+		"Memory limit",
+		currentService.resourceMemoryLimitMb,
+		desiredMemory,
+	);
+}
+
+async function syncReplicas(
+	serviceId: string,
+	currentService: Pick<LinkValidationService, "autoPlace" | "replicas">,
+	desiredReplicas: number,
+	changes: ManifestChange[],
+) {
+	if (!currentService.autoPlace) {
+		throw new Error(
+			"CLI v1 only supports auto-placement. This service uses manual placement.",
+		);
+	}
+
+	if (currentService.replicas === desiredReplicas) {
+		return;
+	}
+
+	await updateServiceAutoPlace(serviceId, true);
+	await updateServiceReplicas(serviceId, desiredReplicas);
+	recordChange(changes, "Replicas", currentService.replicas, desiredReplicas);
+}
+
+async function assertSupportedExistingService(serviceId: string) {
+	const validation = await getServiceLinkValidation(serviceId);
+	if (!validation) {
+		throw new Error("Service not found");
+	}
+
+	if (validation.unsupportedReason) {
+		throw new Error(validation.unsupportedReason);
+	}
+
+	return validation.service;
+}
+
+export async function applyManifest(
+	manifest: TechulusManifest,
+): Promise<ManifestApplyResult> {
+	let serviceCreated = false;
+	let project = await findProjectByManifest(manifest);
+	if (!project) {
+		await createProject(manifest.project.trim());
+		project = await findProjectByManifest(manifest);
+	}
+	if (!project) {
+		throw new Error("Failed to create project");
+	}
+
+	let environment = await findEnvironmentByManifest(project.id, manifest);
+	if (!environment) {
+		await createEnvironment(project.id, manifest.environment.trim());
+		environment = await findEnvironmentByManifest(project.id, manifest);
+	}
+	if (!environment) {
+		throw new Error("Failed to create environment");
+	}
+
+	let service = await findServiceByManifest(project.id, environment.id, manifest);
+	const changes: ManifestChange[] = [];
+
+	if (!service) {
+		serviceCreated = true;
+		const validation = await validateDockerImage(manifest.service.source.image);
+		if (!validation.valid) {
+			throw new Error(validation.error || "Invalid image");
+		}
+
+		await createService({
+			projectId: project.id,
+			environmentId: environment.id,
+			name: getManifestServiceName(manifest),
+			image: manifest.service.source.image,
+		});
+		service = await findServiceByManifest(project.id, environment.id, manifest);
+		if (!service) {
+			throw new Error("Failed to create service");
+		}
+
+		recordChange(changes, "Image", null, manifest.service.source.image);
+		recordChange(
+			changes,
+			"Replicas",
+			null,
+			manifest.service.replicas.count,
+		);
+	}
+
+	const currentService = await assertSupportedExistingService(service.id);
+
+	await syncHostname(
+		service.id,
+		currentService.hostname,
+		manifest.service.hostname ?? null,
+		changes,
+	);
+	await syncImage(
+		service.id,
+		currentService.image,
+		manifest.service.source.image,
+		changes,
+	);
+	await syncPorts(service.id, manifest.service.ports, changes);
+	await syncHealthCheck(service.id, currentService, manifest, changes);
+	await syncStartCommand(
+		service.id,
+		currentService.startCommand,
+		manifest.service.startCommand ?? null,
+		changes,
+	);
+	await syncResources(service.id, currentService, manifest, changes);
+	await syncReplicas(
+		service.id,
+		currentService,
+		manifest.service.replicas.count,
+		changes,
+	);
+
+	const refreshedProject = await findProjectByManifest(manifest);
+	const refreshedEnvironment = await findEnvironmentByManifest(project.id, manifest);
+
+	if (!refreshedProject || !refreshedEnvironment) {
+		throw new Error("Failed to reload manifest resources after apply");
+	}
+
+	return {
+		project: refreshedProject,
+		environment: refreshedEnvironment,
+		serviceId: service.id,
+		action: serviceCreated ? "created" : changes.length === 0 ? "noop" : "updated",
+		changes,
+	};
+}
+
+export async function deployManifest(manifest: TechulusManifest) {
+	const project = await findProjectByManifest(manifest);
+	if (!project) {
+		throw new Error("Project not found");
+	}
+
+	const environment = await findEnvironmentByManifest(project.id, manifest);
+	if (!environment) {
+		throw new Error("Environment not found");
+	}
+
+	const service = await findServiceByManifest(project.id, environment.id, manifest);
+	if (!service) {
+		throw new Error("Service not found");
+	}
+
+	const result = await deployService(service.id);
+
+	return {
+		serviceId: service.id,
+		rolloutId: "rolloutId" in result ? result.rolloutId : null,
+		status: "migrationStarted" in result ? "migration_started" : "queued",
+	};
+}
+
+export async function getManifestStatus(identity: ManifestIdentity) {
+	const project = await findProjectBySlug(identity.project);
+	if (!project) {
+		return null;
+	}
+
+	const environment = await findEnvironmentByName(
+		project.id,
+		slugify(identity.environment),
+	);
+	if (!environment) {
+		return null;
+	}
+
+	const service = await findServiceByName(
+		project.id,
+		environment.id,
+		identity.service.trim(),
+	);
+	if (!service) {
+		return null;
+	}
+
+	const [latestRollout] = await db
+		.select({
+			id: rollouts.id,
+			status: rollouts.status,
+			currentStage: rollouts.currentStage,
+			createdAt: rollouts.createdAt,
+		})
+		.from(rollouts)
+		.where(eq(rollouts.serviceId, service.id))
+		.orderBy(desc(rollouts.createdAt))
+		.limit(1);
+
+	const serviceDeployments = await db
+		.select({
+			id: deployments.id,
+			status: deployments.status,
+			serverId: deployments.serverId,
+			createdAt: deployments.createdAt,
+		})
+		.from(deployments)
+		.where(eq(deployments.serviceId, service.id))
+		.orderBy(desc(deployments.createdAt));
+
+	const ports = await db
+		.select({
+			id: servicePorts.id,
+			port: servicePorts.port,
+			isPublic: servicePorts.isPublic,
+			domain: servicePorts.domain,
+			protocol: servicePorts.protocol,
+		})
+		.from(servicePorts)
+		.where(eq(servicePorts.serviceId, service.id));
+
+	return {
+		service: {
+			id: service.id,
+			name: service.name,
+			image: service.image,
+			hostname: service.hostname,
+			replicas: service.replicas,
+			sourceType: service.sourceType,
+		},
+		ports,
+		latestRollout: latestRollout ?? null,
+		deployments: serviceDeployments,
+	};
+}
+
+export async function listLinkTargets(): Promise<LinkTargetsResult> {
+	const [projectRows, environmentRows, serviceRows, portRows, volumeRows] =
+		await Promise.all([
+			db
+				.select({
+					id: projects.id,
+					name: projects.name,
+					slug: projects.slug,
+				})
+				.from(projects)
+				.orderBy(projects.createdAt),
+			db
+				.select({
+					id: environments.id,
+					projectId: environments.projectId,
+					name: environments.name,
+				})
+				.from(environments)
+				.orderBy(environments.createdAt),
+			db
+				.select({
+					id: services.id,
+					name: services.name,
+					projectId: services.projectId,
+					environmentId: services.environmentId,
+					sourceType: services.sourceType,
+					stateful: services.stateful,
+					autoPlace: services.autoPlace,
+					replicas: services.replicas,
+					resourceCpuLimit: services.resourceCpuLimit,
+					resourceMemoryLimitMb: services.resourceMemoryLimitMb,
+				})
+				.from(services)
+				.orderBy(services.createdAt),
+			db
+				.select({
+					serviceId: servicePorts.serviceId,
+					protocol: servicePorts.protocol,
+					isPublic: servicePorts.isPublic,
+					domain: servicePorts.domain,
+				})
+				.from(servicePorts),
+			db.select({ serviceId: serviceVolumes.serviceId })
+				.from(serviceVolumes)
+				.orderBy(serviceVolumes.id),
+		]);
+
+	const projectNameById = new Map(
+		projectRows.map((project) => [project.id, project.name]),
+	);
+	const environmentById = new Map(
+		environmentRows.map((environment) => [environment.id, environment]),
+	);
+
+	const portsByServiceId = new Map<string, PortCompatibilityRecord[]>();
+	for (const port of portRows) {
+		const current = portsByServiceId.get(port.serviceId) ?? [];
+		current.push(port);
+		portsByServiceId.set(port.serviceId, current);
+	}
+
+	const volumeCountByServiceId = new Map<string, number>();
+	for (const volume of volumeRows) {
+		volumeCountByServiceId.set(
+			volume.serviceId,
+			(volumeCountByServiceId.get(volume.serviceId) ?? 0) + 1,
+		);
+	}
+
+	const servicesByEnvironmentId = new Map<string, LinkServiceTarget[]>();
+	for (const service of serviceRows) {
+		const projectName = projectNameById.get(service.projectId);
+		const environment = environmentById.get(service.environmentId);
+		if (!projectName || !environment) {
+			continue;
+		}
+
+		const current = servicesByEnvironmentId.get(service.environmentId) ?? [];
+		const ports = portsByServiceId.get(service.id) ?? [];
+		const unsupportedReason = getUnsupportedReason(
+			service,
+			ports,
+			volumeCountByServiceId.get(service.id) ?? 0,
+		);
+
+		current.push({
+			id: service.id,
+			name: service.name,
+			project: projectName,
+			environment: environment.name,
+			linkSupported: unsupportedReason === null,
+			unsupportedReason,
+		});
+		servicesByEnvironmentId.set(service.environmentId, current);
+	}
+
+	const environmentsByProjectId = new Map<string, LinkEnvironmentTarget[]>();
+	for (const environment of environmentRows) {
+		const current = environmentsByProjectId.get(environment.projectId) ?? [];
+		current.push({
+			id: environment.id,
+			name: environment.name,
+			services: servicesByEnvironmentId.get(environment.id) ?? [],
+		});
+		environmentsByProjectId.set(environment.projectId, current);
+	}
+
+	return {
+		projects: projectRows.map((project) => ({
+			id: project.id,
+			name: project.name,
+			slug: project.slug,
+			environments: environmentsByProjectId.get(project.id) ?? [],
+		})),
+	};
+}
+
+export async function exportManifestForLinkedService(
+	serviceId: string,
+): Promise<LinkManifestResult> {
+	const validation = await getServiceLinkValidation(serviceId);
+	if (!validation) {
+		throw new Error("Service not found");
+	}
+
+	if (validation.unsupportedReason) {
+		throw new Error(validation.unsupportedReason);
+	}
+
+	const [project, environment] = await Promise.all([
+		db
+			.select()
+			.from(projects)
+			.where(eq(projects.id, validation.service.projectId))
+			.limit(1),
+		db
+			.select()
+			.from(environments)
+			.where(eq(environments.id, validation.service.environmentId))
+			.limit(1),
+	]);
+
+	const projectRow = project[0];
+	const environmentRow = environment[0];
+
+	if (!projectRow || !environmentRow) {
+		throw new Error("Failed to resolve the selected service");
+	}
+
+	const manifest = techulusManifestSchema.parse({
+		apiVersion: "v1",
+		project: projectRow.name,
+		environment: environmentRow.name,
+		service: {
+			name: validation.service.name,
+			source: {
+				type: "image",
+				image: validation.service.image,
+			},
+			...(validation.service.hostname
+				? { hostname: validation.service.hostname }
+				: {}),
+			ports: validation.ports.map((port) => ({
+				port: port.port,
+				public: port.isPublic,
+				...(port.isPublic && port.domain ? { domain: port.domain } : {}),
+			})),
+			replicas: {
+				count: validation.service.replicas,
+			},
+			...(validation.service.healthCheckCmd
+				? {
+						healthCheck: {
+							cmd: validation.service.healthCheckCmd,
+							interval: validation.service.healthCheckInterval ?? 10,
+							timeout: validation.service.healthCheckTimeout ?? 5,
+							retries: validation.service.healthCheckRetries ?? 3,
+							startPeriod: validation.service.healthCheckStartPeriod ?? 30,
+						},
+					}
+				: {}),
+			...(validation.service.startCommand
+				? { startCommand: validation.service.startCommand }
+				: {}),
+			...(validation.service.resourceCpuLimit !== null &&
+			validation.service.resourceMemoryLimitMb !== null
+				? {
+						resources: {
+							cpuCores: validation.service.resourceCpuLimit,
+							memoryMb: validation.service.resourceMemoryLimitMb,
+						},
+					}
+				: {}),
+		},
+	});
+
+	return {
+		manifest,
+		service: {
+			id: validation.service.id,
+			name: validation.service.name,
+			project: projectRow.name,
+			environment: environmentRow.name,
+		},
+	};
+}
diff --git a/web/next.config.ts b/web/next.config.ts
index 398b0d5..42b02f4 100644
--- a/web/next.config.ts
+++ b/web/next.config.ts
@@ -1,7 +1,13 @@
 import type { NextConfig } from "next";
 
+const allowedDevOrigins = (process.env.ALLOWED_DEV_ORIGINS ?? "")
+	.split(",")
+	.map((value) => value.trim())
+	.filter(Boolean);
+
 const nextConfig: NextConfig = {
 	output: "standalone",
+	allowedDevOrigins,
 };
 
 export default nextConfig;
