Skip to content

feat(worker): add nixpacks build pack with static/server paths - #39

Merged
Alimedhat000 merged 8 commits into
mainfrom
12/nixpacks-build-pack
May 18, 2026
Merged

feat(worker): add nixpacks build pack with static/server paths#39
Alimedhat000 merged 8 commits into
mainfrom
12/nixpacks-build-pack

Conversation

@Alimedhat000

Copy link
Copy Markdown
Owner
  • Remove "static" build pack, merge into nixpacks with isStatic toggle
  • Add isStatic column to apps table (default true)
  • Update pipeline to route nixpacks to new strategy
  • New nixpacks.ts: clone → nixpacks build → extract (static) or
    runLongLived (server) → Caddy route
  • Install nixpacks + docker-buildx in worker image
  • Fix Caddy SPA fallback: use file_server pass_thru instead of
    subroute errors
  • Mock child_process in pipeline tests for nixpacks CLI calls
    Closes Nixpacks build pack — auto-detect framework with Nixpacks #12

- Remove "static" build pack, merge into nixpacks with isStatic toggle
- Add isStatic column to apps table (default true)
- Update pipeline to route nixpacks to new strategy
- New nixpacks.ts: clone → nixpacks build → extract (static) or
  runLongLived (server) → Caddy route
- Install nixpacks + docker-buildx in worker image
- Fix Caddy SPA fallback: use file_server pass_thru instead of
  subroute errors
- Mock child_process in pipeline tests for nixpacks CLI calls
Closes #12
New /app/:id route for editing app config and redeploying without
recreating the app. Uses existing PUT /api/apps/:id endpoint.
- AppSettings page with dynamic fields per build pack
- useApp and useUpdateApp hooks
- Settings button on Dashboard app cards
- Saves only dirty fields to avoid validation errors
- remove duplicate nixpacks run command in CreateAppModal

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Merges the previously-separate static build pack into the nixpacks build pack with an isStatic toggle, adds a new worker strategy that drives nixpacks end-to-end (clone → build → extract or run long-lived → Caddy route), installs nixpacks + docker-buildx in the worker image, fixes the Caddy SPA fallback to use file_server/pass_thru, and adds an AppSettings page in the web UI.

Changes:

  • Schema/migration drop the static enum value, add apps.is_static boolean default true, switch default build_pack to nixpacks, and update all validators/UI/types to match.
  • New deployNixpacks strategy replaces deployBuildPack; previous step files and static.ts are flagged as dead code. Pipeline tests are rewritten to mock node:child_process.
  • Caddy SPA route now uses file_server pass_thru + rewrite + file_server instead of subroute/errors; ADRs 0004/0006/0012 are updated; worker mounts /var/run/docker.sock; web adds AppSettings page and useApp/useUpdateApp hooks.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
packages/worker/src/deployments/strategies/nixpacks.ts New nixpacks build/run strategy invoking nixpacks and Docker CLI via execSync.
packages/worker/src/deployments/pipeline.ts Routes non-Dockerfile deploys to deployNixpacks; drops static strategy import.
packages/worker/src/deployments/steps/*.ts, errors/classify-error.ts, strategies/static.ts Marked as dead-code comments only.
packages/worker/src/infrastructure/caddy/config-builder.ts Replaces subroute+errors SPA fallback with file_server pass_thru pattern.
packages/worker/src/index.ts Reconcile routes branches on isStatic for nixpacks.
packages/worker/Dockerfile(.dev) Installs docker-buildx and nixpacks (amd64 binaries).
packages/worker/test/unit/deployments/pipeline.test.ts Rewrites tests for new strategy and mocks child_process.
packages/worker/test/unit/infrastructure/caddy-config.test.ts Asserts the new three-handler SPA route shape.
packages/shared/src/schema.ts, validators/app.ts, constants/build-pack.ts Drops static enum, adds isStatic, default pack nixpacks.
drizzle/0007_medical_terror.sql, meta/* Migration adding is_static and recreating build_pack enum without static.
packages/api/src/services/apps.ts Exposes isStatic/installCommand in safe columns.
packages/web/src/components/CreateAppModal.tsx, BuildPackSelector.tsx Removes static option; adds isStatic checkbox under nixpacks.
packages/web/src/pages/AppSettings.tsx, Dashboard.tsx, hooks/useApps.ts, main.tsx New per-app settings page + routes/hooks.
docker-compose.yaml Mounts host Docker socket into the worker container.
docs/adr/0004,0006,0012 Documents the merge of static into nixpacks and the new Caddy fallback.
Comments suppressed due to low confidence (8)

packages/worker/src/deployments/strategies/nixpacks.ts:125

  • The worker now mounts /var/run/docker.sock, so docker create / docker cp invoked from inside the worker run against the host Docker daemon and interpret paths in the host's filesystem, not the worker container's. sitesPath is built as /var/lib/shipyard/sites/{appId} (a path that exists inside the container via the shipyard_sites named volume); on the host this path almost certainly doesn't exist (the volume's data lives under /var/lib/docker/volumes/shipyard_sites/_data). As a result docker cp will either fail or write extracted files to a host directory that Caddy never reads, breaking static deployments end-to-end. The sites directory needs to be a bind mount on a real host path (shared with Caddy) or extraction must happen via a helper container with the volume mounted.
	const imageTag = `shipyard-${appId}:${workspacePath.split("/").pop()}`;
	const containerName = `shipyard-extract-${appId}-${Date.now()}`;
	const sitesPath = path.join(getEnv().SITES_DIR, appId);

	try {
		execSync(`docker create --name ${containerName} ${imageTag}`, {
			stdio: "pipe",
		});

		const containerPath = `/app/${outputDir}`;

		try {
			execSync(`docker cp ${containerName}:${containerPath}/. ${sitesPath}`, {
				stdio: "pipe",
			});
		} catch {
			execSync(`docker cp ${containerName}:/app/. ${sitesPath}`, {
				stdio: "pipe",
			});
		}

		execSync(`docker rm ${containerName}`, { stdio: "pipe" });
	} catch (err) {
		const msg = err instanceof Error ? err.message : "extraction failed";
		throw new Error(`Failed to extract static output: ${msg}`);
	}
}

packages/worker/src/deployments/strategies/nixpacks.ts:120

  • All docker commands here are invoked through execSync with template-string interpolation, which goes through /bin/sh. Several interpolated values come from user-controlled data (app.id, and especially outputDir, which validators/app.ts only constrains to be a non-empty string). A value like dist; rm -rf / for outputDir would result in shell command injection running on the worker with full Docker socket access — i.e. host root-equivalent. Use execFileSync/spawnSync with argument arrays (no shell), or strictly validate these inputs before interpolation.
	try {
		execSync(`docker create --name ${containerName} ${imageTag}`, {
			stdio: "pipe",
		});

		const containerPath = `/app/${outputDir}`;

		try {
			execSync(`docker cp ${containerName}:${containerPath}/. ${sitesPath}`, {
				stdio: "pipe",
			});
		} catch {
			execSync(`docker cp ${containerName}:/app/. ${sitesPath}`, {
				stdio: "pipe",
			});
		}

		execSync(`docker rm ${containerName}`, { stdio: "pipe" });

packages/worker/src/deployments/strategies/nixpacks.ts:118

  • sitesPath (/var/lib/shipyard/sites/{appId}) is never created before docker cp writes into it. The first time an app is deployed the directory will not exist and docker cp will fail (and the destination won't have the right ownership/permissions). Create the directory with fs.mkdirSync(sitesPath, { recursive: true }) before the copy, and ideally clear any stale contents from a previous deploy so old files don't linger.
	const sitesPath = path.join(getEnv().SITES_DIR, appId);

	try {
		execSync(`docker create --name ${containerName} ${imageTag}`, {
			stdio: "pipe",
		});

		const containerPath = `/app/${outputDir}`;

		try {
			execSync(`docker cp ${containerName}:${containerPath}/. ${sitesPath}`, {
				stdio: "pipe",
			});
		} catch {
			execSync(`docker cp ${containerName}:/app/. ${sitesPath}`, {
				stdio: "pipe",
			});
		}

packages/worker/src/deployments/strategies/nixpacks.ts:124

  • If docker create succeeds but the subsequent docker cp throws (e.g. both the primary and the /app/. fallback fail), control jumps to the outer catch and docker rm ${containerName} is never executed — the extraction container is leaked on the host. Wrap the cleanup in a finally (or run docker rm -f unconditionally) so the temporary container is always removed.
	try {
		execSync(`docker create --name ${containerName} ${imageTag}`, {
			stdio: "pipe",
		});

		const containerPath = `/app/${outputDir}`;

		try {
			execSync(`docker cp ${containerName}:${containerPath}/. ${sitesPath}`, {
				stdio: "pipe",
			});
		} catch {
			execSync(`docker cp ${containerName}:/app/. ${sitesPath}`, {
				stdio: "pipe",
			});
		}

		execSync(`docker rm ${containerName}`, { stdio: "pipe" });
	} catch (err) {
		const msg = err instanceof Error ? err.message : "extraction failed";
		throw new Error(`Failed to extract static output: ${msg}`);
	}

packages/worker/src/deployments/strategies/nixpacks.ts:69

  • spawn is invoked with cwd: repoDir, but repoDir lives inside the worker container while nixpacks (as configured in the Dockerfiles in this PR) runs in the worker container and shells out to the host Docker daemon via the mounted socket. The repoDir path the worker passes to nixpacks will not be visible to the host's Docker daemon, so any layer that mounts the source into a build container will fail to find files. Nixpacks documents that it requires Docker to see the build context at the same path it was given — this needs special handling (e.g. bind mount the workspace dir on a host path that's shared into the worker at the same path, similar to BUILD_WORKSPACE_DIR setup).
		const proc = spawn("nixpacks", args, {
			cwd: repoDir,
			stdio: ["ignore", "pipe", "pipe"],
		});

packages/worker/src/deployments/strategies/nixpacks.ts:253

  • The app.buildTimeout field is fetched but never enforced anywhere in this strategy — there is no setTimeout/abort tied to the nixpacks spawn or the long-running container start. The previous static strategy honored buildTimeout (the old test "times out when build takes too long" used buildTimeout = 0), but with this PR that behavior is silently dropped and a hung nixpacks build can run forever, blocking the worker.

		// Step 2: Nixpacks build
		await createBuildJobRow(db, deploymentId, "nixpacks-build");
		await insertStructuredEvent(
			db,
			deploymentId,
			"nixpacks-build",
			'Step "nixpacks-build" started',
		);

		const isStatic = app.isStatic ?? true;

		try {
			await runNixpacksBuild(
				workspacePath,
				app,
				envMap,
				subdirectory,
				isStatic,
			);
			await finalizeBuildJobRow(db, deploymentId, "nixpacks-build", true, 1);
		} catch (err) {
			await finalizeBuildJobRow(db, deploymentId, "nixpacks-build", false, 0);
			throw err;
		}
		await insertStructuredEvent(
			db,
			deploymentId,
			"nixpacks-build",
			'Step "nixpacks-build" completed',
		);
		logger.info({ deploymentId }, "Nixpacks build completed");

packages/worker/src/deployments/strategies/nixpacks.ts:383

  • Both extractStaticOutput and the long-lived runLongLived paths assume Docker is reachable, but the strategy already accepts that "extraction failed" can mean anything — including a structural problem (volume vs bind-mount mismatch, missing buildx, missing nixpacks). The user-facing structured event only includes err.message truncated to 2000 chars from nixpacks. Consider distinct event kinds for "tooling missing" vs "build failed" so the UI / acceptance-criteria "fail with clear message listing detected files" can be honored.
	} catch (err) {
		logger.error({ err, deploymentId }, "Nixpacks deployment failed");
		await insertStructuredEvent(
			db,
			deploymentId,
			"system",
			`Nixpacks build error: ${err instanceof Error ? err.message : "Unknown error"}`,
		);
		await db
			.update(deployments)
			.set({ status: "failed", finishedAt: new Date() })
			.where(eq(deployments.id, deploymentId));
	} finally {

packages/worker/src/deployments/strategies/nixpacks.ts:336

  • runLongLived is called for a server-mode app, but there is no health-check after start (acceptance criterion: "Health check after start"), no explicit --restart=unless-stopped ("Container restart policy: unless-stopped"), and the long-lived path doesn't appear to honor user-overridden port validation (port comes from app.port ?? 80, but for a server app the user is expected to be able to override the detected port). Verify whether runLongLived internally sets restart policy and health-check; if not, this strategy is missing several issue #12 requirements.
		} else {
			// Server path: stop old, start new long-lived container
			const port = app.port ?? 80;
			const containerName = `shipyard-app-${app.id}`;

			await createBuildJobRow(db, deploymentId, "start");
			logger.info({ deploymentId }, "Starting long-lived container");

			await runner.stopByName(containerName);
			await new Promise((r) => setTimeout(r, 2000));

			try {
				await runner.runLongLived({
					image: imageTag,
					containerName,
					containerPort: port,
					envVars: envMap,
					labels: {
						"shipyard.managed": "true",
						"shipyard.type": "app",
						"shipyard.app-id": app.id,
						"shipyard.worker-id": env.WORKER_ID ?? "worker-unknown",
					},
				});
				await finalizeBuildJobRow(db, deploymentId, "start", true, 1);
			} catch (err) {
				await finalizeBuildJobRow(db, deploymentId, "start", false, 0);
				throw err;
			}

			await insertStructuredEvent(
				db,
				deploymentId,
				"start",
				'Step "start" completed',
			);
			logger.info({ containerName, port }, "Long-lived container started");

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +21 to +27
try {
spawnSync("nixpacks", ["--version"], { stdio: "pipe" });
} catch {
throw new Error(
"Nixpacks is not installed. Install it with: curl -fsSL https://nixpacks.com/install.sh | sh",
);
}
Comment thread packages/worker/src/deployments/strategies/nixpacks.ts
Comment on lines +62 to +67
for (const [key, value] of Object.entries(envMap)) {
args.push("--env", `${key}=${value}`);
}

const proc = spawn("nixpacks", args, {
cwd: repoDir,
Comment on lines +71 to +86
const chunks: Buffer[] = [];
proc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
proc.stderr?.on("data", (chunk: Buffer) => chunks.push(chunk));

proc.on("close", (code) => {
if (code === 0) {
resolve();
} else {
const output = Buffer.concat(chunks).toString();
reject(
new Error(
`Nixpacks build failed (exit ${code}): ${output.slice(0, 2000)}`,
),
);
}
});
Comment on lines +28 to +34
RUN apk add --no-cache docker-cli curl tar gzip
RUN mkdir -p /usr/local/lib/docker/cli-plugins && \
curl -fsSL https://github.com/docker/buildx/releases/download/v0.20.0/buildx-v0.20.0.linux-amd64 \
-o /usr/local/lib/docker/cli-plugins/docker-buildx && \
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
RUN curl -fsSL https://github.com/railwayapp/nixpacks/releases/download/v1.41.0/nixpacks-v1.41.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz -C /usr/local/bin nixpacks
Comment on lines 110 to 245
@@ -160,143 +163,84 @@ describe("DeploymentOrchestrator", () => {
);
});

it("fails on missing GitHub token — no container created", async () => {
it("fails on missing GitHub token — no clone", async () => {
const ctx = [
{
deployment: { id: "deploy-2" },
app: { id: "app-2", name: "myapp", githubRepo: "user/repo" },
deployment: { id: "deploy-nx-2" },
app: {
id: "app-nx-2",
name: "myapp-nx",
githubRepo: "user/repo",
buildPack: "nixpacks",
},
githubAccessToken: null,
userId: "user-2",
},
];
const deps = makeDeps([ctx, [], []]);
const orchestrator = new DeploymentOrchestrator(deps);

await orchestrator.process("deploy-2");

expect(deps.runner.create).not.toHaveBeenCalled();
expect(deps.runner.remove).not.toHaveBeenCalled();
});

it("marks deployment as 'failed' when GitHub token is missing", async () => {
const ctx = [
{
deployment: { id: "deploy-3" },
app: { id: "app-3", name: "myapp", githubRepo: "user/repo" },
githubAccessToken: null,
userId: "user-3",
},
];
const deps = makeDeps([ctx, [], []]);
const orchestrator = new DeploymentOrchestrator(deps);

await orchestrator.process("deploy-3");

const updates = (deps.db as any).update.mock.results;
const failedUpdate = updates
.map((r: any) => r.value.set.mock.calls[0]?.[0])
.find((s: any) => s?.status === "failed");
expect(failedUpdate).toBeDefined();
});

it("times out when build takes too long", async () => {
setupOutput("deploy-4");
const appCtx = makeAppContext();
appCtx[0].app.buildTimeout = 0;
const deps = makeDeps([appCtx, [], []]);
const orchestrator = new DeploymentOrchestrator(deps);

await orchestrator.process("deploy-4");
await orchestrator.process("deploy-nx-2");

expect(deps.runner.remove).toHaveBeenCalledWith("container-1");
expect(deps.logger.error).toHaveBeenCalled();
const updates = (deps.db as any).update.mock.results;
const lastSet = updates[updates.length - 1].value.set;
expect(lastSet).toHaveBeenCalledWith(
expect.objectContaining({ status: "failed" }),
);
expect(deps.runner.runOnce).not.toHaveBeenCalled();
});

it("marks deployment as failed when clone step fails", async () => {
setupOutput("deploy-5");
const deps = makeDeps([makeAppContext(), [], []]);
(deps.runner as any).exec = vi.fn().mockResolvedValue({
exitCode: 128,
oomKilled: false,
stdout: "",
stderr: "Permission denied",
});
it("marks deployment as failed when clone fails", async () => {
const deps = makeDeps([makeStaticAppContext(), [], []]);
deps.runner.runOnce = vi.fn().mockResolvedValue(128);
const orchestrator = new DeploymentOrchestrator(deps);

await orchestrator.process("deploy-5");
await orchestrator.process("deploy-nx-1");

expect(deps.runner.remove).toHaveBeenCalledWith("container-1");
expect(deps.upsertFileRoute).not.toHaveBeenCalled();
expect(deps.upsertProxyRoute).not.toHaveBeenCalled();
const updates = (deps.db as any).update.mock.results;
const lastSet = updates[updates.length - 1].value.set;
expect(lastSet).toHaveBeenCalledWith(
expect.objectContaining({ status: "failed" }),
);
});
});

it("cleans up container and workspace on unexpected error", async () => {
setupOutput("deploy-6");
const deps = makeDeps([makeAppContext(), [], []]);
(deps.runner as any).create = vi
.fn()
.mockRejectedValue(new Error("docker error"));
const orchestrator = new DeploymentOrchestrator(deps);

await orchestrator.process("deploy-6");

const updates = (deps.db as any).update.mock.results;
const failedSet = updates
.map((r: any) => r.value.set.mock.calls[0]?.[0])
.find((s: any) => s?.status === "failed");
expect(failedSet).toBeDefined();
});
describe("nixpacks server (isStatic=false)", () => {
const BUILD_DIR = "/tmp/shipyard-test/builds";

it("works with subdirectory — verify step checks repo/{subdir}/dist", async () => {
const appCtx = [
function makeServerAppContext() {
return [
{
deployment: { id: "deploy-sub-1" },
deployment: { id: "deploy-nx-srv-1" },
app: {
id: "app-sub-1",
name: "myapp-sub",
id: "app-nx-srv-1",
name: "myapp-nx-srv",
githubRepo: "user/repo",
buildPack: "nixpacks",
isStatic: false,
port: 3000,
runCommand: "npm start",
buildTimeout: 900,
outputDir: "dist",
subdirectory: "frontend",
isSpa: false,
branch: "main",
},
githubAccessToken: "gh_token_123",
userId: "user-1",
},
];
const dir = path.join(
BUILD_DIR,
"deploy-sub-1",
"repo",
"frontend",
"dist",
);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "index.html"), "<h1>sub</h1>");
}

const deps = makeDeps([appCtx, [], []]);
afterEach(() => {
fs.rmSync(BUILD_DIR, { recursive: true, force: true, maxRetries: 3 });
});

it("routes to nixpacks server — clones, runs, and proxies", async () => {
const deps = makeDeps([makeServerAppContext(), [], []]);
const orchestrator = new DeploymentOrchestrator(deps);

await orchestrator.process("deploy-sub-1");
await orchestrator.process("deploy-nx-srv-1");

expect(deps.runner.create).toHaveBeenCalledTimes(1);
expect(deps.runner.remove).toHaveBeenCalledWith("container-1");
expect(deps.upsertFileRoute).toHaveBeenCalledWith(
"app-sub-1",
"myapp-sub.bigboss.dev",
false,
expect(deps.runner.runOnce).toHaveBeenCalledWith(
expect.objectContaining({ image: "alpine/git" }),
);
expect(deps.runner.stopByName).toHaveBeenCalledWith(
"shipyard-app-app-nx-srv-1",
);
expect(deps.runner.runLongLived).toHaveBeenCalled();
});
});
it("marks deployment as 'building' on start", async () => {
setupOutput("deploy-1");
const deps = makeDeps([makeAppContext(), [], []]);
it("mark deployment as 'success' after completion", async () => {
Comment on lines +33 to +64
export function useApp(appId: string) {
return useQuery({
queryKey: ["app", appId],
queryFn: async () => {
const res = await fetch(`/api/apps/${appId}`, { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch app");
return res.json();
},
});
}

export function useUpdateApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: Record<string, unknown>) => {
const res = await fetch(`/api/apps/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(data),
});
const body = await res.json();
if (!res.ok)
throw new Error(body.message ?? body.error ?? "Failed to update app");
return body;
},
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: ["app", vars.id] });
qc.invalidateQueries({ queryKey: ["apps"] });
},
});
}
if (subdirectory) payload.subdirectory = subdirectory;
} else {
if (runCommand) payload.runCommand = runCommand;
if (outputDir) payload.outputDir = outputDir;
Comment on lines +75 to +91
proc.on("close", (code) => {
if (code === 0) {
resolve();
} else {
const output = Buffer.concat(chunks).toString();
reject(
new Error(
`Nixpacks build failed (exit ${code}): ${output.slice(0, 2000)}`,
),
);
}
});

proc.on("error", (err) => {
reject(new Error(`Failed to start nixpacks: ${err.message}`));
});
});
- Stream stdout/stderr directly to LogBuffer instead of unbounded
in-memory buffer
- Pass env vars via --env-file (mode 0600) instead of --env CLI args
- Fix spawnSync check for nixpacks binary — it returns error object,
never throws
- Add TARGETARCH support to Dockerfiles for arm64 builds
@Alimedhat000
Alimedhat000 merged commit ec3aa5b into main May 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nixpacks build pack — auto-detect framework with Nixpacks

2 participants