Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export default defineConfig({
"serve": "server",
"port": 3000,
"healthCheckPath": "/",
"vm": { "memory": "512mb" }, // asked at init; edit to resize the VM
"internalDeps": ["@acme/ui", "@acme/database"],
"secrets": ["DATABASE_URL", "SESSION_SECRET"], // runtime env vars
"buildEnv": ["VITE_API_URL"], // baked in at build time
Expand Down Expand Up @@ -226,6 +227,24 @@ Every generated `fly.toml` includes an HTTP health check:

Fly waits for this check to pass before shifting traffic to a new release, and **keeps the old machines running if it fails** — so a bad deploy rolls itself back. If your app's `/` returns a 404 (e.g. an API with no root route), set `healthCheckPath` for that app in `deploykit.config.ts` to a lightweight endpoint like `/health`, or the deploy would wedge.

## VM sizing, concurrency & build cache

deploykit shapes these per app from the workload rather than hardcoding one size for everything:

- **VM memory** — because memory maps directly to cost and to whether an app OOMs, `deploykit init` **asks per app**, pre-filled with a workload-aware suggestion (static `256mb`, Next SSR `1024mb` — Next commonly OOMs at 512mb — other servers `512mb`). Hit Enter to accept, or type another value like `2gb`. Your choice is recorded in the config, so regeneration never resizes the VM under you. `--yes` takes the suggestion for every app; CPU stays `shared-cpu-1x`.
- **Concurrency** — the thresholds at which Fly load-balances to and cold-starts another machine. Fly's own default is a low `20/25`; deploykit emits a workload-aware `[http_service.concurrency]` instead — servers cap by `requests` (SSR is CPU-bound), static apps by `connections` (cheap to serve many).
- **Build cache** — every generated Dockerfile mounts a per-manager BuildKit cache on the install layer (`RUN --mount=type=cache …`), so re-installs reuse already-downloaded packages across builds. The `# syntax=docker/dockerfile:1` directive that enables it is always emitted; without BuildKit the mount is ignored, not an error.

These are starting points — the right numbers come from real traffic. Override memory, CPU or concurrency per app in `deploykit.config.ts` (regeneration keeps your values):

```ts
"web": {
// …
"vm": { "size": "shared-cpu-2x", "memory": "2048mb" },
"concurrency": { "type": "requests", "softLimit": 300, "hardLimit": 400 }
}
```

## Rolling back a release

When a release deployed cleanly but turned out bad, redeploy a previous image:
Expand Down
2 changes: 1 addition & 1 deletion examples/apps/marketing/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ FROM base AS build
WORKDIR /app
# Lockfile + package manifests first for a cacheable install layer.
COPY --from=prune /app/out/json/ .
RUN pnpm install --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Then the pruned source, and build.
COPY --from=prune /app/out/full/ .
RUN pnpm turbo run build --filter=@acme/marketing
Expand Down
15 changes: 14 additions & 1 deletion examples/apps/marketing/fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ primary_region = "iad"
min_machines_running = 0
processes = ["app"]

# Concurrency: the thresholds at which Fly load-balances to and cold-starts
# another machine. Fly's default is a low 20/25; this baseline is workload-
# aware — a server caps by requests (SSR is CPU-bound), a static app by
# connections (cheap to serve many). Tune to real traffic, or override via
# `concurrency` in deploykit.config.ts.
[http_service.concurrency]
type = "connections"
soft_limit = 400
hard_limit = 500

# Health check: Fly waits for this to pass before shifting traffic to a new
# release, and keeps the old machines running if it fails — auto-rollback on a
# bad deploy. If "/" 404s (e.g. an API with no root route), point
Expand All @@ -26,6 +36,9 @@ primary_region = "iad"
timeout = "5s"
grace_period = "30s"

# VM: workload-aware default (static 256mb · Next SSR 1024mb · other servers
# 512mb, all shared-cpu-1x). Override with `vm` in deploykit.config.ts so
# `deploykit generate` keeps your sizing.
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
memory = "256mb"
2 changes: 1 addition & 1 deletion examples/apps/web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ FROM base AS build
WORKDIR /app
# Lockfile + package manifests first for a cacheable install layer.
COPY --from=prune /app/out/json/ .
RUN pnpm install --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Then the pruned source, and build.
COPY --from=prune /app/out/full/ .
ARG VITE_API_URL
Expand Down
13 changes: 13 additions & 0 deletions examples/apps/web/fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ primary_region = "iad"
min_machines_running = 0
processes = ["app"]

# Concurrency: the thresholds at which Fly load-balances to and cold-starts
# another machine. Fly's default is a low 20/25; this baseline is workload-
# aware — a server caps by requests (SSR is CPU-bound), a static app by
# connections (cheap to serve many). Tune to real traffic, or override via
# `concurrency` in deploykit.config.ts.
[http_service.concurrency]
type = "requests"
soft_limit = 200
hard_limit = 250

# Health check: Fly waits for this to pass before shifting traffic to a new
# release, and keeps the old machines running if it fails — auto-rollback on a
# bad deploy. If "/" 404s (e.g. an API with no root route), point
Expand All @@ -36,6 +46,9 @@ primary_region = "iad"
timeout = "5s"
grace_period = "30s"

# VM: workload-aware default (static 256mb · Next SSR 1024mb · other servers
# 512mb, all shared-cpu-1x). Override with `vm` in deploykit.config.ts so
# `deploykit generate` keeps your sizing.
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
10 changes: 8 additions & 2 deletions examples/deploykit.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ export default defineConfig({
],
"buildEnv": [
"VITE_API_URL"
]
],
"vm": {
"memory": "512mb"
}
},
"marketing": {
"root": "apps/marketing",
Expand All @@ -93,7 +96,10 @@ export default defineConfig({
}
},
"secrets": [],
"outputDir": "dist"
"outputDir": "dist",
"vm": {
"memory": "256mb"
}
}
},
"namePrefix": "acme",
Expand Down
22 changes: 22 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export type Framework =
/** How the runner stage serves the app. Decoupled from `Framework`. */
export type ServeModel = "static" | "server";

/** How Fly counts load for autoscaling / load-balancing (`http_service.concurrency`). */
export type ConcurrencyType = "requests" | "connections";

/**
* A workspace package that ships a Prisma schema. Its client isn't generated on
* install under pnpm 10 / Prisma 7, so the Dockerfile runs `prisma generate`
Expand Down Expand Up @@ -157,6 +160,25 @@ export interface AppConfig {
* (e.g. "/health") for an API whose "/" 404s, or it would wedge the deploy.
*/
healthCheckPath?: string;
/**
* Override the runner VM. Omitted → a workload-aware default in fly.toml, all
* on shared-cpu-1x: static apps get 256mb, a Next SSR server 1024mb (Next
* commonly OOMs at 512mb), other servers 512mb. Set either field to pin it so
* `deploykit generate` keeps your sizing.
*/
vm?: { size?: string; memory?: string };
/**
* Override Fly's `http_service.concurrency` — the thresholds at which Fly
* load-balances to and cold-starts another machine. Omitted → a workload-aware
* default: servers cap by "requests" (200/250, SSR is CPU-bound), static apps
* by "connections" (400/500, cheap to serve many). Fly's own default is a low
* connections 20/25.
*/
concurrency?: {
type?: ConcurrencyType;
softLimit?: number;
hardLimit?: number;
};
/** Names of internal workspace packages this app depends on. */
internalDeps: string[];
/**
Expand Down
2 changes: 1 addition & 1 deletion src/generate/dockerfile-nx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe("generateDockerfile (Nx, package-based / server model)", () => {
config: { ...pkgBasedConfig, installEnv: { LEFTHOOK: "0" } },
});
expect(withEnv).toContain(
"RUN pnpm install --frozen-lockfile --ignore-scripts",
"RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --ignore-scripts",
);
expect(withEnv).toContain(
'RUN cd packages/db && DATABASE_URL="postgresql://build:build@localhost:5432/build" pnpm exec prisma generate',
Expand Down
4 changes: 2 additions & 2 deletions src/generate/dockerfile-nx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
baseStage,
buildEnvLines,
fileHeader,
installLine,
installStep,
nodeImage,
PM,
prismaSteps,
Expand Down Expand Up @@ -32,7 +32,7 @@ export function nxDockerfile({ app, config }: GenerateAppFileInput) {
FROM base AS build
WORKDIR /app
COPY . .
RUN ${installLine(pm, config)}
${installStep({ pm, config })}
${buildEnvLines(app)}${prismaSteps(app, pm)}RUN ${pm.run} nx build ${project}${productionFlag}
`;

Expand Down
45 changes: 42 additions & 3 deletions src/generate/dockerfile-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ export interface PmCommands {
dlx: string;
/** Frozen install from the lockfile. */
install: string;
/**
* BuildKit cache mount for the install layer — the manager's package
* store/cache dir, persisted across builds so re-installs skip re-downloading.
* `id` namespaces the cache; `dir` is the mount target. `flag`, when set, pins
* the manager's cache to `dir` (yarn/bun default it elsewhere); pnpm's store
* already lands in `/pnpm` (PNPM_HOME) and npm caches in `/root/.npm`, so they
* need none. The `# syntax` directive enables the mount; without BuildKit it's
* ignored, not an error.
*/
cacheId: string;
cacheDir: string;
cacheFlag?: string;
}

export const PM: Record<PackageManager, PmCommands> = {
Expand All @@ -22,32 +34,43 @@ export const PM: Record<PackageManager, PmCommands> = {
exec: "pnpm exec",
dlx: "pnpm dlx",
install: "pnpm install --frozen-lockfile",
cacheId: "pnpm",
cacheDir: "/pnpm/store",
},
npm: {
run: "npx",
exec: "npx",
dlx: "npx --yes",
install: "npm ci",
cacheId: "npm",
cacheDir: "/root/.npm",
},
yarn: {
run: "yarn",
exec: "yarn exec",
dlx: "yarn dlx",
install: "yarn install --frozen-lockfile",
cacheId: "yarn",
cacheDir: "/root/.yarn-cache",
cacheFlag: "--cache-folder /root/.yarn-cache",
},
bun: {
run: "bunx",
exec: "bunx",
dlx: "bunx",
install: "bun install",
cacheId: "bun",
cacheDir: "/root/.bun/install/cache",
},
};

/**
* How the runner serves an app. Prefers the detected `serve` field; falls back
* to the framework for configs generated before that field existed.
*/
export const serveModel = (app: AppConfig): ServeModel =>
export const serveModel = (
app: Pick<AppConfig, "serve" | "framework">,
): ServeModel =>
app.serve ??
(app.framework === "next" ||
app.framework === "remix" ||
Expand All @@ -67,7 +90,7 @@ export const serveModel = (app: AppConfig): ServeModel =>
* install instead (`--ignore-scripts`). Prisma generate and the build run as
* their own steps, so nothing needed at build time depends on those scripts.
*/
export const installLine = (pm: PmCommands, config: DeploykitConfig) => {
const installLine = (pm: PmCommands, config: DeploykitConfig) => {
const env = { ...config.installEnv };
const ignoreScripts = "LEFTHOOK" in env;
delete env.LEFTHOOK; // no-op at install time — dropped in favor of --ignore-scripts
Expand All @@ -76,9 +99,25 @@ export const installLine = (pm: PmCommands, config: DeploykitConfig) => {
.map(([k, v]) => `${k}=${v}`)
.join(" ")} `
: "";
return `${prefix}${pm.install}${ignoreScripts ? " --ignore-scripts" : ""}`;
const cacheFlag = pm.cacheFlag ? ` ${pm.cacheFlag}` : "";
return `${prefix}${pm.install}${cacheFlag}${ignoreScripts ? " --ignore-scripts" : ""}`;
};

/**
* The workspace install as a `RUN` with a BuildKit cache mount on the package
* manager's store/cache, so re-installs reuse already-downloaded packages across
* builds — the biggest build-speed lever in a monorepo. The mount needs BuildKit
* (enabled by the Dockerfile's `# syntax` directive) and is ignored otherwise.
*/
export const installStep = ({
pm,
config,
}: {
pm: PmCommands;
config: DeploykitConfig;
}) =>
`RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheDir} ${installLine(pm, config)}`;

/**
* ARG/ENV lines for the app's build-time vars (NEXT_PUBLIC_*, VITE_*, and all
* vars of a static app). Declared in the build stage so `--build-arg` values
Expand Down
4 changes: 2 additions & 2 deletions src/generate/dockerfile-turbo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
baseStage,
buildEnvLines,
fileHeader,
installLine,
installStep,
nodeImage,
PM,
prismaSteps,
Expand Down Expand Up @@ -34,7 +34,7 @@ FROM base AS build
WORKDIR /app
# Lockfile + package manifests first for a cacheable install layer.
COPY --from=prune /app/out/json/ .
RUN ${installLine(pm, config)}
${installStep({ pm, config })}
# Then the pruned source, and build.
COPY --from=prune /app/out/full/ .
${buildEnvLines(app)}${prismaSteps(app, pm)}RUN ${pm.run} turbo run build --filter=${filter}
Expand Down
29 changes: 24 additions & 5 deletions src/generate/dockerfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,22 @@ describe("generateDockerfile", () => {
};
expect(gen("next", npmConfig)).toContain("npx --yes turbo prune");
// Frozen install from the lockfile — `npm install` may rewrite it.
expect(gen("next", npmConfig)).toContain("RUN npm ci");
expect(gen("next", npmConfig)).toContain(
"RUN --mount=type=cache,id=npm,target=/root/.npm npm ci",
);
});

it("mounts a per-manager BuildKit cache on the install layer", () => {
// The `# syntax` directive that enables the mount is always emitted.
expect(gen("next")).toContain("# syntax=docker/dockerfile:1");
expect(gen("next")).toContain(
"RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile",
);
const yarn = gen("next", { ...sampleConfig, packageManager: "yarn" });
// yarn defaults its cache elsewhere, so the target is pinned with a flag.
expect(yarn).toContain(
"RUN --mount=type=cache,id=yarn,target=/root/.yarn-cache yarn install --frozen-lockfile --cache-folder /root/.yarn-cache",
);
});

it("installs bun in the base stage (corepack doesn't provide it)", () => {
Expand All @@ -40,10 +55,12 @@ describe("generateDockerfile", () => {
};
const out = gen("next", bunConfig);
expect(out).toContain("RUN npm install -g bun");
expect(out).toContain("RUN bun install");
expect(out).toContain(
"RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache bun install",
);
// bun must be installed before the workspace install runs.
expect(out.indexOf("npm install -g bun")).toBeLessThan(
out.indexOf("RUN bun install"),
out.indexOf("bun install"),
);
});

Expand Down Expand Up @@ -94,7 +111,7 @@ describe("generateDockerfile", () => {
// Lefthook's `prepare` hook can't be neutralized by LEFTHOOK=0 (it shells
// out to git first), so lifecycle scripts are skipped on the install.
expect(out).toContain(
"RUN pnpm install --frozen-lockfile --ignore-scripts",
"RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --ignore-scripts",
);
// No prisma.config → the --schema flag is present.
expect(out).toContain(
Expand All @@ -116,7 +133,9 @@ describe("generateDockerfile", () => {
app: appWith("node-server"),
config,
});
expect(out).toContain("RUN HUSKY=0 pnpm install --frozen-lockfile");
expect(out).toContain(
"RUN --mount=type=cache,id=pnpm,target=/pnpm/store HUSKY=0 pnpm install --frozen-lockfile",
);
expect(out).not.toContain("--ignore-scripts");
});

Expand Down
Loading
Loading