feat(worker): add nixpacks build pack with static/server paths - #39
Conversation
- 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
There was a problem hiding this comment.
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
staticenum value, addapps.is_static boolean default true, switch defaultbuild_packtonixpacks, and update all validators/UI/types to match. - New
deployNixpacksstrategy replacesdeployBuildPack; previous step files andstatic.tsare flagged as dead code. Pipeline tests are rewritten to mocknode:child_process. - Caddy SPA route now uses
file_server pass_thru+ rewrite +file_serverinstead ofsubroute/errors; ADRs 0004/0006/0012 are updated; worker mounts/var/run/docker.sock; web addsAppSettingspage anduseApp/useUpdateApphooks.
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, sodocker create/docker cpinvoked from inside the worker run against the host Docker daemon and interpret paths in the host's filesystem, not the worker container's.sitesPathis built as/var/lib/shipyard/sites/{appId}(a path that exists inside the container via theshipyard_sitesnamed 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 resultdocker cpwill 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
dockercommands here are invoked throughexecSyncwith template-string interpolation, which goes through/bin/sh. Several interpolated values come from user-controlled data (app.id, and especiallyoutputDir, whichvalidators/app.tsonly constrains to be a non-empty string). A value likedist; rm -rf /foroutputDirwould result in shell command injection running on the worker with full Docker socket access — i.e. host root-equivalent. UseexecFileSync/spawnSyncwith 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 beforedocker cpwrites into it. The first time an app is deployed the directory will not exist anddocker cpwill fail (and the destination won't have the right ownership/permissions). Create the directory withfs.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 createsucceeds but the subsequentdocker cpthrows (e.g. both the primary and the/app/.fallback fail), control jumps to the outercatchanddocker rm ${containerName}is never executed — the extraction container is leaked on the host. Wrap the cleanup in afinally(or rundocker rm -funconditionally) 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
spawnis invoked withcwd: repoDir, butrepoDirlives inside the worker container whilenixpacks(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. TherepoDirpath 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.buildTimeoutfield is fetched but never enforced anywhere in this strategy — there is nosetTimeout/abort tied to the nixpacks spawn or the long-running container start. The previous static strategy honoredbuildTimeout(the old test "times out when build takes too long" usedbuildTimeout = 0), but with this PR that behavior is silently dropped and a hungnixpacks buildcan 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
extractStaticOutputand the long-livedrunLongLivedpaths 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 includeserr.messagetruncated 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
runLongLivedis 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-overriddenportvalidation (port comes fromapp.port ?? 80, but for a server app the user is expected to be able to override the detected port). Verify whetherrunLongLivedinternally 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.
| 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", | ||
| ); | ||
| } |
| for (const [key, value] of Object.entries(envMap)) { | ||
| args.push("--env", `${key}=${value}`); | ||
| } | ||
|
|
||
| const proc = spawn("nixpacks", args, { | ||
| cwd: repoDir, |
| 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)}`, | ||
| ), | ||
| ); | ||
| } | ||
| }); |
| 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 |
| @@ -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 () => { |
| 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; |
| 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
runLongLived (server) → Caddy route
subroute errors
Closes Nixpacks build pack — auto-detect framework with Nixpacks #12