Automate CI/CD for Turbo & Nx monorepos deploying to Fly.io.
Run one command in your monorepo and get a reviewable PR that wires up PR previews, staging, and production — as files you own.
deploykit reads your workspace graph, figures out which apps are deployable, and generates the Dockerfiles, fly.toml files, and a GitHub Actions workflow — all landed as files you own and can edit. There is no runtime, no backend, and no lock-in: once the files are in your repo, deploykit is optional.
- PR preview environments — every pull request gets its own deployed app, with the URL commented on the PR and torn down automatically when the PR closes.
- Staging — deploys on merge to
main. - Production — deploys behind a manual approval gate (GitHub Environment protection).
- Requirements
- Quick start
- How it works
- Commands & flags
- What it generates
- The config file
- Environments
- Secrets & environment variables
- Health checks & automatic rollback
- Rolling back a release
- Multiple regions
- Custom domains (Cloudflare)
- Database migrations
- Provisioning & opening a PR
- Framework & monorepo support
- Security & privacy
- Development
- License
| Need | For |
|---|---|
| A git repository | deploykit opens a PR with the generated files. |
A Turbo (turbo.json) or Nx (nx.json) monorepo |
The workspace deploykit reads and deploys. |
| Node.js ≥ 20 | Running the CLI. |
gh — authenticated |
Only for --pr and --provision (opening the PR, setting Actions secrets). |
flyctl — authenticated |
Only for --provision / --deploy and deploykit rollback. |
CLOUDFLARE_API_TOKEN |
Only if you wire custom domains through Cloudflare. |
Detection, planning, and file generation work with none of the CLIs signed in — you only need gh/flyctl when you opt into provisioning, a PR, or a deploy.
npx @alminabrulic/deploykit initPrefer a global install (gives you the shorter deploykit command used throughout these docs)?
npm i -g @alminabrulic/deploykit
deploykit initinit is interactive: it detects everything it can, asks a handful of questions (each pre-filled from detection), shows you the exact plan, and does nothing until you confirm.
# Accept every detected default, print the plan, write nothing:
deploykit init --yes --org my-org --region iad --dry-run
# Write the files for just preview + staging:
deploykit init --yes --org my-org --envs preview,staging
# The full deal: provision Fly apps + secrets and open a PR:
deploykit init --yes --org my-org --region iad --provision --prdeploykit init runs five phases, and nothing is written or provisioned until you confirm the plan:
- Preflight — verifies you're in a git repo with a Turbo or Nx monorepo, and checks whether
gh/flyctlare available and authenticated. - Detect — reads your package manager, workspace packages, each app's framework, ports, internal dependencies, Prisma schemas, and the env-var names it references.
- Ask — a few questions, each pre-filled from detection: which apps, which environments, Fly org and region(s).
- Plan — shows exactly which files will be written (new / unchanged / overwrite if you hand-edited one) and what will be provisioned.
- Emit — writes the files and, if you opted in, provisions Fly apps, sets GitHub secrets, opens a PR, and/or deploys staging.
deploykit init [options] Detect the monorepo and set everything up
deploykit generate [options] Regenerate Dockerfiles/workflow/fly.toml from
deploykit.config.ts (overwrites them)
deploykit rollback [options] Redeploy a prior image for one environment's Fly app
init— the full detect → ask → plan → emit flow above. Run it once to set up, or again any time (it's re-runnable and never silently clobbers your edits).generate— readsdeploykit.config.tsand re-emits every Dockerfile,fly.toml, and the workflow from it. Use it after you edit the config by hand. Regenerating is the point, so this overwrites those files even if you've customized them — it lists what it will overwrite and asks first (pass--yesto skip the prompt, or--dry-runto just see the list). The config file itself is never touched; it's the input.rollback— see Rolling back a release.
| Flag | Applies to | Description |
|---|---|---|
-y, --yes |
init, generate | Accept detected defaults, no prompts. |
--org <slug> |
init | Fly organization slug. |
--region <list> |
init | Fly region(s), comma-separated. The first is the primary; any others are extra stateless regions (e.g. iad,lhr,fra). Default: iad. |
--envs <list> |
init | Environments to configure: preview,staging,production (default: all). |
--dry-run |
init, generate | Detect and print the plan, but write nothing. |
--provision |
init | Create Fly apps, set the FLY_API_TOKEN secret, and create GitHub environments (each step confirmed). Forces provisioning in --yes mode; interactive runs offer it inline. |
--deploy |
init | Deploy the staging app(s) to Fly at the end of the run. |
--pr |
init | Commit the generated files on a branch and open a PR. |
--force |
init | Overwrite existing generated files instead of skipping them. (generate always overwrites.) |
--cwd <dir> |
all | Run against a different directory. |
--app <name> |
rollback | App to roll back (defaults to the sole app). |
--env <kind> |
rollback | Environment: staging or production. |
--to <version> |
rollback | Release version to redeploy (non-interactive). |
-h, --help |
— | Show help. |
-v, --version |
— | Show version. |
deploykit.config.ts source of truth — every other file regenerates from this
.dockerignore repo-root; keeps secrets & junk out of the build context
.github/workflows/deploy.yml changes → preview / teardown / staging / production jobs
apps/<app>/Dockerfile multi-stage, turbo-prune (or nx build) based
apps/<app>/fly.toml per-app Fly config with a health check
DEPLOYMENTS.md the map: every URL, plus deep links into Fly /
Cloudflare / GitHub — see below
DEPLOYMENTS.md is written for the teammate who didn't run deploykit init: a table of every app environment (URL, Fly app, what deploys it), a mermaid diagram of the pipeline, and a link straight to each Fly app's logs, Cloudflare's DNS page for your zone, and the GitHub environments/secrets screens — so nothing has to be hunted down console by console. It lists secret names and where they're wired, never values, which is why it's a committed file rather than a gitignored one. See examples/DEPLOYMENTS.md for a real one.
See it for real: the
examples/directory contains the actual, byte-for-byte output deploykit produces for a two-app Turbo monorepo (a React Router SSRwebapp with Prisma + a custom domain + multi-region, and a static Astromarketingapp). Every file there is generated from a singleexamples/deploykit.config.ts.
Every file carries a # Generated by deploykit header and is yours to edit and commit. Re-running init skips files you've changed unless you pass --force, so a hand-edited Dockerfile is never overwritten by surprise. generate is the deliberate exception — it re-emits everything from the config, so it overwrites your edits (after showing you the list and asking).
deploykit.config.ts is the single source of truth. It's a typed TypeScript file, but its payload is a plain object literal so deploykit generate can read it back — as data, without ever executing your config — and regenerate everything. Edit it, then run deploykit generate.
import { defineConfig } from "@alminabrulic/deploykit";
export default defineConfig({
"tool": "turbo",
"packageManager": "pnpm",
"nodeVersion": "20",
"namePrefix": "acme", // prefixes every global Fly app name → acme-web-staging
"provider": {
"type": "fly",
"org": "acme",
"region": "iad",
"regions": ["iad", "lhr"] // extra stateless regions (optional)
},
"apps": {
"web": {
"root": "apps/web",
"packageName": "@acme/web",
"framework": "react-router",
"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
"environments": {
"preview": { "name": "web-pr-{pr}", "trigger": "pr" },
"staging": { "name": "web-staging", "trigger": "push:main" },
"production": { "name": "web-prod", "trigger": "manual", "hostname": "shop.example.com" }
}
}
// ... more apps
}
});The full file — including the Prisma target, the marketing app, and the Cloudflare block — is at examples/deploykit.config.ts.
The one rule is that every value must be literal data: strings, numbers, booleans, arrays, objects. Variables, imports, spreads and template placeholders are rejected (with the offending line) rather than guessed at. Everything else is fair game — quoted or bare keys, single or double quotes, // and /* */ comments, trailing commas — so running Prettier, Biome, or your editor's formatter over the file is safe.
deploykit models three environments; you choose which with --envs.
| Environment | Trigger | Fly app name | Lifecycle |
|---|---|---|---|
| preview | every pull request | …-<app>-pr-<number> |
Created/updated on each push; URL commented on the PR (one comment, updated in place); destroyed when the PR closes. Single machine, single region. |
| staging | push to main |
…-<app>-staging |
Deployed automatically. Uses the GitHub staging environment. |
| production | manual (workflow_dispatch) |
…-<app>-prod |
Deployed only when you dispatch the workflow; gated by the GitHub production environment (add required reviewers there for an approval gate). Runs HA (two machines). |
Only the apps whose files actually changed get deployed on a given run — the workflow's changes job uses dorny/paths-filter against each app's own directory plus its internal dependencies' directories.
The generated workflow: examples/.github/workflows/deploy.yml.
deploykit detects the env-var names your apps reference (never values) and wires them through GitHub Actions secrets. It distinguishes two kinds:
- Runtime secrets (
secrets) — set on the Fly app viaflyctl secrets setat deploy time (e.g.DATABASE_URL,SESSION_SECRET). Read by the running process. - Build-time vars (
buildEnv) — baked into the bundle duringdocker buildvia--build-arg+ DockerfileARG/ENV. These are client-exposed prefixes (NEXT_PUBLIC_,VITE_, …) and every var of a static app (which has no runtime to read env from).
You add the values once as repository secrets in GitHub (Settings → Secrets and variables → Actions); the workflow references them by name. Values containing quotes or $ are passed through shell variables, never interpolated into the script — so they can't break or inject into the deploy step.
Every generated fly.toml includes an HTTP health check:
[[http_service.checks]]
method = "GET"
path = "/"
interval = "15s"
timeout = "5s"
grace_period = "10s"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.
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 initasks per app, pre-filled with a workload-aware suggestion (static256mb, Next SSR1024mb— Next commonly OOMs at 512mb — other servers512mb). Hit Enter to accept, or type another value like2gb. Your choice is recorded in the config, so regeneration never resizes the VM under you.--yestakes the suggestion for every app; CPU staysshared-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 byrequests(SSR is CPU-bound), static apps byconnections(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:1directive 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):
"web": {
// …
"vm": { "size": "shared-cpu-2x", "memory": "2048mb" },
"concurrency": { "type": "requests", "softLimit": 300, "hardLimit": 400 }
}When a release deployed cleanly but turned out bad, redeploy a previous image:
deploykit rollback --app web --env productionIt lists that environment's Fly releases, lets you pick one, shows the exact flyctl deploy --image … it will run, and asks before doing it. Script it with --to <version> --yes:
deploykit rollback --app web --env production --to 41 --yes
⚠️ This rolls back the app image only — it does not undo database migrations. An older image may not run against a schema a newer release migrated, so prefer additive (expand/contract) migrations.
Pass more than one region and the extras become stateless regions the app is scaled into after each staging/production deploy (previews stay single-region):
deploykit init --region iad,lhr,fra # primary iad, plus lhr and fraYou can also set regions under provider in deploykit.config.ts. Each extra region gets one machine via flyctl scale count 1 --region <r> after the deploy (best-effort — a transient scale failure warns but doesn't fail an already-successful deploy).
This is for stateless apps. deploykit does not model database locality, so a far-region machine still talks to whatever single-region
DATABASE_URLyou set — expect high write latency. Read replicas /fly-replayare out of scope.
Set a hostname on a staging/production environment and add a cloudflare block to the config, and deploykit issues a Fly certificate and wires the Cloudflare DNS records for you (previews stay on *.fly.dev). The Cloudflare block controls proxying, SSL mode, minimum TLS, an "Always Use HTTPS" edge redirect, a security baseline, and static-asset cache rules:
"cloudflare": {
"zone": "example.com",
"proxied": true,
"ssl": "strict",
"alwaysUseHttps": true,
"minTlsVersion": "1.2",
"security": true,
"cache": true
}This is entirely optional — omit it and deploykit leaves DNS alone. The Cloudflare token stays in the git-ignored .deploykit/credentials file (mode 0600) or the CLOUDFLARE_API_TOKEN env var, and is never committed.
deploykit does not run migrations — a bad one causes irreversible data loss, and owning that is out of scope. Instead, when it detects a Prisma schema in an app it writes a commented-out hook into that app's fly.toml:
# [deploy]
# release_command = "(cd packages/database && npx prisma migrate deploy --schema ./prisma/schema.prisma)"[deploy].release_command is Fly's idiomatic migration hook: it runs once per release, before new machines take traffic. Uncomment it only against a database you own, and make sure the Prisma CLI and schema are present in your runtime image. Note that deploykit rollback reverts the image only — it does not undo a migration this hook applied, so prefer additive (expand/contract) migrations. Using another tool (Drizzle, Knex, …)? Uncomment and swap the command for its migrate step.
Some apps need a codegen step between install and build — a Prisma/Drizzle client, GraphQL or protobuf types. deploykit models this as a generic list of buildSteps, run in the build stage after install and before the framework build. Nothing is hardcoded to one tool:
"buildSteps": [
{ "run": "pnpm exec drizzle-kit generate", "workdir": "packages/db" },
{ "run": "pnpm codegen" }
]Prisma is just the auto-detected default. When deploykit finds a Prisma schema it fills in the right step for you — prisma generate for every Prisma package in the app's dependency closure (it isn't generated on install under pnpm 10 / Prisma 7, so it has to run before the build). It's recorded under prisma in your config; detected targets render first, then your own buildSteps. Nothing forces Prisma — remove the prisma block to opt out, or add your own steps for a different stack. deploykit itself pulls in no database or ORM dependency; these are just commands it writes into the Dockerfile.
By default init only writes files. Opt into the side effects:
--provision— for each environment's app:flyctl apps create(if missing), set theFLY_API_TOKENrepository secret, and create the GitHubstaging/productionenvironments. Each step is confirmed; in interactive mode it's offered inline.--pr— commit the generated files on adeploykit/setupbranch and open a pull request. Re-runnable: it reuses the branch and an existing open PR, and skips committing when nothing changed.--deploy— after generating (and provisioning), deploy the staging app(s) to Fly directly, so you can see it live without waiting for a merge.
Monorepo tools
- Turbo — full support via
turbo prunemulti-stage Docker builds. - Nx — supported via
nx build+dist/<projectRoot>output. Node-server and static (Vite/Astro) apps are solid; Next/SSR Dockerfiles follow Nx conventions but are worth a glance before your first deploy.
App types — the runner is chosen per app from a serve model, not hardcoded per framework:
- Server apps (Next, Remix, React Router, Astro-node, or any
node-server) run their long-running process. The CMD is package-manager-free so it works in the slim runtime image. Generic server apps (everything but Next's standalone output) copy the built workspace into the runner, so the build stage runs aprune --prodfirst — the toolchain (vite, typescript, …) is dropped and only production dependencies ship. pnpm and npm prune in place (preserving the generated Prisma client); yarn/bun keep the full copy. - Static apps (Vite, Astro static, plain static) are served with
serve(SPA history fallback available viaspa).
Deploy target — Fly.io.
Not in scope (v1) — deploykit provisions no database (it detects Prisma and writes the opt-in, commented migration hook above; the database itself is yours to create and own), and does not model stateful multi-region.
deploykit is a local CLI with no backend — it never sends your credentials anywhere except directly to GitHub, Fly, and Cloudflare to do the work you ask for, and it collects no telemetry.
- GitHub sign-in uses the OAuth device flow; the token is stored by the official
ghCLI (your OS keychain or~/.config/gh/hosts.yml) — deploykit keeps no copy. - Fly is handled by
flyctl; the CI deploy token is written only as your repo'sFLY_API_TOKENGitHub Actions secret. - Cloudflare tokens (optional) stay in the git-ignored
.deploykit/credentialsfile (mode0600) or an env var — never committed.
You can verify all of this: the code is source-available (see src/auth.ts and src/secrets-file.ts), and releases ship with npm provenance. Full details, exactly what each provider accesses, and revocation steps are in SECURITY.md.
To report a vulnerability, please use GitHub's private vulnerability reporting rather than a public issue.
pnpm install
pnpm dev init --dry-run # run the CLI from source (tsx)
pnpm build # bundle to dist/ (tsup) + emit config.d.ts (tsc)
pnpm test # vitest
pnpm typecheck # tsc --noEmit
pnpm lint # biome checkThe generated config is the source of truth: every Dockerfile, fly.toml, and workflow is regenerable from deploykit.config.ts. When you change generation logic, add a Vitest test covering the output (generated shell/YAML must stay safe against injection from repo-derived values). See REVIEW.md for the review guidelines.
deploykit is source-available under the Business Source License 1.1 (BUSL-1.1).
- ✅ Free to read, modify, self-host, and use for non-production and non-commercial production purposes.
- 💳 Using deploykit in production for a commercial purpose (building, deploying, or operating software that is sold or offered as a paid service) requires a commercial license — get in touch.
- 🔓 Each released version automatically converts to the Apache License 2.0 four years after its release (its Change Date).
See LICENSE for the full terms.