Skip to content

Deploying

blix deploy builds your app as a Docker image, pushes it to a registry, and can run one command afterwards so your host picks the new image up. The same command runs on your laptop and in CI, so a deploy behaves the same in both places.

Targets: Docker, Vercel, Netlify and Cloudflare Workers. See Compatibility for what has actually been verified for each.

Starting a new project? pnpm create blixis my-app --deploy docker --ci github does steps 1 and 2 for you (any target, any CI provider; see Installation). For an existing project:

Terminal window
pnpm add -D @blixis-io/cli @blixis-io/deploy
# or, from a project that already has the CLI:
blix add deploy
Terminal window
blix deploy init --ci github

This writes four files and never overwrites one that exists (pass --force to replace them):

File What it is
blix.config.ts The deploy section: one target, prod. The image name is worked out from your GitHub remote (ghcr.io/<owner>/<repo>).
Dockerfile A multi-stage build: compile everything, ship only production dependencies and dist/, run as the node user. pnpm and npm projects only. For yarn or bun, write your own and set dockerfile on the target.
.dockerignore Keeps node_modules, .git and .env* out of the image.
.github/workflows/deploy.yml Runs blix deploy prod on every push to main.

For Vercel or Netlify, see Vercel and Netlify below. Everything from “Look before you run” on works the same for every target.

The generated Dockerfile ships dist/ and the production dependencies, and nothing else. An app that applies its migrations from the same image (blix run db:migrate) also needs its migrations/ folder, blix.config.ts and @blixis-io/cli in the runtime image, so write your own Dockerfile and set dockerfile on the target. examples/saas-api/Dockerfile is one that does, and CI builds it from a copy of the example outside the repository, migrates a throwaway Postgres with it twice, calls it, checks it does not run as root and stops it with SIGTERM, expecting a graceful exit (node scripts/docker-image.mjs). Run migrations as a deploy step, once, not from every replica: see Database operations.

Terminal window
blix deploy --dry-run
# deploy prod (docker), tag 6185c92
$ printenv REGISTRY_PASSWORD | docker login ghcr.io -u $REGISTRY_USERNAME --password-stdin
$ docker build -t ghcr.io/acme/api:6185c92 -f Dockerfile .
$ docker push ghcr.io/acme/api:6185c92
# would need these environment variables: REGISTRY_USERNAME, REGISTRY_PASSWORD

--dry-run prints every command and runs none of them. The password is piped to docker login on stdin, so it never appears in a command line or in the output.

blix deploy doctor checks that the config is valid, that git and Docker are available, and which required variables are set.

Terminal window
REGISTRY_USERNAME=me REGISTRY_PASSWORD=<token> blix deploy

Steps run in order and stop at the first failure, which is named. A run that is missing a required variable refuses to start rather than failing halfway. blix deploy build only builds: no login, push or post-push command, and no credentials needed.

The image tag is, in order: the target’s tag, $BLIX_TAG, the short git commit, then latest.

Add an after command to the target. It runs once the push succeeds and sees BLIX_IMAGE (ghcr.io/acme/api:6185c92) and BLIX_TAG:

blix.config.ts
import { defineConfig } from "@blixis-io/cli";
export default defineConfig({
deploy: {
targets: {
prod: {
type: "docker",
image: "ghcr.io/acme/api",
registry: { host: "ghcr.io" },
after: 'fly deploy --image "$BLIX_IMAGE"',
env: ["FLY_API_TOKEN"],
},
},
},
});

That one hook is how Fly, Railway, Render, or a server you reach over SSH pick up the new image. env lists variables the target needs: doctor checks them, and the generated workflow passes them through from your repository secrets.

targets: {
staging: { type: "docker", image: "ghcr.io/acme/api-staging", registry: { host: "ghcr.io" } },
prod: { type: "docker", image: "ghcr.io/acme/api", registry: { host: "ghcr.io" } },
},
default: "staging",

blix deploy prod picks one; with no argument it uses default, or the only target. Generate a workflow for a specific one with blix deploy ci github prod. A target can’t be named init, build, ci, doctor or help.

Terminal window
blix deploy init --target vercel --ci github
blix deploy init --target netlify --ci github

Both put your app behind a single serverless function. The generated entry is one plain JavaScript file that imports your compiled app (dist/, from your own build script) and exports a fetch handler:

api/index.mjs (Vercel)
import { createFetchHandler } from "@blixis-io/http";
import { AppModule } from "../dist/app.module.js";
export default createFetchHandler(AppModule);

It needs @blixis-io/http 0.3 or newer. If your compiled module lives elsewhere, set the shared app location in your existing blix.config.ts:

export default defineConfig({
app: { module: "build/root.js", export: "RootModule" },
// Other config sections...
});

init uses these values for the generated entry, and blix run uses them to load your app. Pass --app-module or --app-export to override either value for this generation. Without flags or config values, the defaults are dist/app.module.js and AppModule. Vercel also gets a vercel.json (every path goes to the function) and a public/ directory it insists on; Netlify gets a netlify.toml and the function in netlify/functions/.

blix deploy then runs your build script and the provider’s CLI through npx:

$ pnpm run build
$ npx --yes vercel@62.2.0 deploy --yes --prod
$ pnpm run build
$ npx --yes netlify-cli@27.11.0 deploy --dir public --functions netlify/functions --prod
Option on the target
production false deploys a preview instead (default true)
build Your own build command, run through the shell (default: the package manager’s run build)
cliVersion The provider CLI version npx runs. Defaults to a pinned version (init writes it into your config); see Provider CLI versions
env Variables the target needs
Netlify: site, dir, functions The site id (else NETLIFY_SITE_ID), publish directory, functions directory

Credentials. Link the project once on your machine (npx vercel link, npx netlify link) and log in; blix deploy doesn’t ask for a token. In CI, set VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID (Vercel) or NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID (Netlify) as repository secrets; the generated workflow passes them through, and blix deploy doctor lists them.

Why compiled JavaScript, not TypeScript. Both providers bundle TypeScript with esbuild, which drops the decorator metadata Blixis’ dependency injection needs. Handing them the output of tsc avoids that, and their own tracing then packages node_modules for you.

On a function platform, listen(), shutdownTimeout and SIGTERM handling don’t apply. See Running in Production.

Terminal window
blix deploy init --target cloudflare --ci github

This writes cloudflare/worker.mjs, a wrangler.toml, and the config target. The entry is plain JavaScript that imports your compiled app (dist/, from your own build script):

cloudflare/worker.mjs
import { createFetchHandler } from "@blixis-io/http";
import { AppModule } from "../dist/app.module.js";
export default createFetchHandler(AppModule);
wrangler.toml
name = "my-api"
main = "cloudflare/worker.mjs"
compatibility_date = "2026-10-02"
compatibility_flags = ["nodejs_compat"]

The Worker name is derived from your package name (lower-case letters, digits and dashes; @acme/My_API becomes my-api). nodejs_compat is required: the framework uses node:async_hooks, node:http and node:stream. The generated import uses app.module and app.export from your existing blix.config; --app-module and --app-export override those values. Needs @blixis-io/http 0.3 or newer.

blix deploy then runs your build script and Wrangler:

$ pnpm run build
$ npx --yes wrangler@4.147.0 deploy
Option on the target
environment A named Wrangler environment: wrangler deploy --env <name>
config A Wrangler config other than wrangler.toml (for example wrangler.jsonc), passed with --config
build, cliVersion, env As for the other targets

Credentials. Run npx wrangler login once on your machine, or set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. In CI the generated workflow passes both through from your secrets, and blix deploy doctor lists them.

No bundler to install. Cloudflare’s own bundler (esbuild, inside Wrangler) builds the Worker. That is safe here because it only ever sees compiled JavaScript: the decorator metadata Blixis needs was already emitted by tsc. Pointing Wrangler at TypeScript source would drop it.

What a Worker can’t do like a server: listen(), shutdownTimeout and SIGTERM handling don’t apply (see Running in Production), and connecting to Postgres from a Worker is not covered by blix deploy and has not been tested; Cloudflare offers its own routes for databases.

The Vercel, Netlify and Cloudflare targets run the provider’s own CLI through npx, with your deploy token in its environment (VERCEL_TOKEN, NETLIFY_AUTH_TOKEN, CLOUDFLARE_API_TOKEN). If that were @latest, whatever version was published most recently would run with your credentials the moment it was published. So the version is pinned:

  • A new target defaults to the version this release of @blixis-io/deploy ships: vercel 62.2.0, netlify-cli 27.11.0, wrangler 4.147.0.
  • blix deploy init writes that version into your blix.config.ts (cliVersion: "62.2.0"), so your repository decides when the CLI changes, not the next npm publish. To upgrade, edit the number.
  • blix deploy doctor shows which version each target runs, and warns if one is set to "latest".
  • You can still opt in to the moving target with cliVersion: "latest". doctor will warn about it, and the ci workflow runs it as written.

What was and wasn’t checked for those three versions: each exists on npm, is not deprecated, supports Node 24 (their engines), and the command blix deploy builds for it was checked with --dry-run. Not checked: a real deployment with them, because that needs a provider account. Treat them as a conservative, known-published starting point, not as a tested-against-production claim, and bump them in your config when you have reason to.

Every deploy section is checked strictly: an option the target doesn’t have is an error, with a suggestion when one is close.

Invalid deploy config in blix.config.ts:
- deploy.targets.prod: unknown option "pussh" (did you mean "push"?)

Before this check, a misspelt option was ignored and the default stayed in force, so pussh: false still pushed the image.

Some names from your flags and blix.config.ts are copied into files blix deploy generates: the Dockerfile’s CMD, and the CI workflow, which runs with your deploy secrets. So each is held to what that kind of name can be, and anything else is refused with a message that names the value (shown as JSON, so an invisible character shows). A newline, a quote, a ; or a $(...) can then never become a second instruction, an extra workflow step or a shell command.

Name Allowed Because it becomes
a target name (the key under deploy.targets, --name) letters, digits, ., _, -, starting with a letter or digit run: blix deploy <name>, a shell command line
an environment variable (env, registry.usernameEnv, passwordEnv) letters, digits, _, not starting with a digit, and not true, false, yes, no, on, off, null, y, n in any case a YAML key and ${{ secrets.NAME }} (YAML would read those words as a boolean or null)
the CI branch (--branch) starts with a letter, digit or _, then letters, digits, ., _, /, -, * branches: ["<name>"] and a shell comparison

The docker entry (--entry) can be any path: it is written as a JSON string, so a quote or a newline in it stays text in one argument of CMD. The branch is always quoted in the file now (branches: ["main"]), because a branch named 1.0 would otherwise be read by YAML as the number 1. If you regenerate a workflow with --force, expect that one-line difference.

If a build fails with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION

Section titled “If a build fails with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION”

A pnpm 11 project that has just upgraded to a fresh @blixis-io/* release can fail its Docker build, its CI install, or Vercel’s build, because pnpm refuses versions younger than 24 hours. It fixes itself after a day, or immediately with minimumReleaseAgeExclude: ["@blixis-io/*"] in pnpm-workspace.yaml. The generated Dockerfile copies pnpm-workspace.yaml, so the setting applies inside the image too. Details and the reasoning: Installation.

The generated workflow is deliberately thin: check out, install, run blix deploy <target>. For ghcr.io it uses GitHub’s own GITHUB_TOKEN and the packages: write permission, so no secrets need setting up. For any other registry it reads the credentials from repository secrets named after the target’s usernameEnv and passwordEnv (default REGISTRY_USERNAME and REGISTRY_PASSWORD).

For Vercel and Netlify the workflow has no registry step and passes the provider’s secrets instead.

The actions are pinned to a commit, not to a tag: actions/checkout@11d5960... # v4. This workflow runs with your deploy secrets, and a tag such as v4 can be moved by whoever controls the action’s repository, so the exact commit is what you trust. The pins are the commits each tag pointed at when your version of @blixis-io/deploy was released, and they change when you upgrade it and regenerate (blix deploy ci github --force). To keep them current in between, let Dependabot propose the updates; it understands the tag in the comment:

.github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
Terminal window
blix deploy init --ci gitlab # .gitlab-ci.yml
blix deploy init --ci bitbucket # bitbucket-pipelines.yml
blix deploy ci gitlab # regenerate for a configured target (also: bitbucket)

Both generate one job that runs in a plain node image on pushes to your branch (--branch, default main): install with your package manager, then blix deploy <target>. The same logic as everywhere else, in a different wrapper. What differs from GitHub:

  • Docker targets need a Docker daemon, which neither system’s default job image has. GitLab gets the docker:27-dind service and the Docker client downloaded into the job; Bitbucket gets its docker service, plus the client downloaded only if the image lacks one. Vercel and Netlify jobs have no Docker part.
  • Secrets are CI/CD variables you set yourself (GitLab: Settings > CI/CD > Variables, masked; Bitbucket: Repository settings > Pipelines > Repository variables, secured). A comment at the top of each generated file lists exactly which. On GitLab, for registry.gitlab.com the credentials come from GitLab’s own CI_REGISTRY_USER / CI_REGISTRY_PASSWORD, so nothing needs setting.
  • pnpm and yarn run corepack enable first, so the version pinned in packageManager is the one used (see the pnpm note above); bun is installed with npm.

Docker: the generated workflow parsed as valid YAML, and the full flow (init, build, a real image built from the generated Dockerfile, run, called, and stopped gracefully) was run against Docker on 2026-10-01. Pushing to a registry and the GitHub Actions run itself are covered by unit tests and a dry run, not exercised against a real registry.

GitLab CI and Bitbucket Pipelines: the generated files were parsed as valid YAML and their structure checked. The job’s own commands were run in a clean node:24 container, the way the CI system would: corepack enable, pnpm install --frozen-lockfile and blix deploy (pnpm, with the pinned pnpm used), and npm ci and blix deploy (npm), each with --dry-run. The Docker client download was run in node:24 on linux/amd64. Not run: the pipelines on GitLab or Bitbucket themselves, so the Docker-in-Docker service on GitLab and Bitbucket’s docker service are unverified.

Cloudflare Workers: from files blix deploy init generated, Wrangler’s own bundler produced the Worker (wrangler deploy --dry-run, 861 KiB / 138 KiB gzipped) and wrangler dev --local ran it in the Workers runtime, answering a request and a 404. The final wrangler deploy needs an account, so it was checked as a dry-run command only.

Vercel and Netlify: from files blix deploy init generated, Vercel’s own vercel build produced a function that answered correctly, and Netlify’s own functions:build produced a zip that answered correctly when extracted and run. The final vercel deploy and netlify deploy calls need an account, so they were checked as dry-run commands only.