Appearance
nkapp deploy
Deploy your application to a VPS via SSH (or to a cloud provider). deploy owns provisioning, host bootstrap and code sync — since the up/deploy split those phases no longer run under nkapp up by default.
bash
nkapp deploy [service] [options]Options
| Flag | Type | Default | Description |
|---|---|---|---|
-e, --env <env> | string | stage | Target environment |
--ssh <host> | string | — | SSH connection string (e.g. root@192.168.1.1). Overrides deploy.target in config. |
-p, --port <port> | number | 22 | SSH port |
-s, --service <name> | string[] | — | Target specific service(s). Repeatable (-s a -s b) or comma-separated (-s a,b); also accepted positionally. |
--all | boolean | false | Deploy every service — disables change-proportional scoping |
--cache | boolean | false | Keep persistent cache volumes (node_modules, build artifacts) on the remote host. By default Nanokit prunes the env-scoped cache paths before each deploy for a clean build. |
--rollback | boolean | false | Roll back to the previous reconciler state |
--force | boolean | false | Bypass health checks during deploy (an unhealthy or slow-starting service will not abort the deploy). Never widens or narrows the scope. |
--strict | boolean | false | Fail fast on the first service error instead of the default heal-and-continue reconcile |
--no-dns | boolean | — | Skip DNS sync during reconcile (DNS managed externally, e.g. by nkapp migrate) |
--env-file <path> | string | — | Use a specific file as the base of the .env → .env.<env> → .env.local hierarchy (the overlay still layers on top) |
--override-env | boolean | false | Let .env file values override variables already exported in the shell / injected by CI. Does not affect file-vs-file layering — .env.<env> always beats .env |
-y, --yes | boolean | false | Skip confirmation prompts |
Environment variables:
| Variable | Effect |
|---|---|
NANOKIT_DEPLOY_ALL=1 | Flag-less equivalent of --all, for CI/scripts that cannot be edited |
Change-proportional by default
deploy resolves a deploy scope before opening any SSH connection and deploys only the services that actually changed since the last successful deploy from this working copy to this target, plus their depends_on / db:// dependents.
It fails open. Any of the following resolves to a full deploy — the same pipeline as before the feature existed:
- a file changed outside every
service.path(shared code) .env*,nanokit.ymlor a lockfile changed- the deploy target changed, or the working copy moved
- the working copy is not a git repo
- the first deploy from a fresh clone
The scope line
Printed once per run, right after the config loads and before any remote round-trip:
Scope: 2/9 services (web, api) — changed: web (source)Names are elided to the first five plus +N more.
The denominator can read high
<total> counts the union of the root and per-environment services: blocks. On an environment whose block omits some root services it reads higher than the set actually evaluated. It is reporting only — it feeds no decision.
Nothing to deploy
When no service changed:
Nothing to deploy — no service changed since the last deploy to '<target>'. Use --all to force.The command exits 0 without touching the target — no cache prune, no rsync, no remote prepare, no reconcile. A real no-op run takes seconds instead of minutes.
Watermarks
State lives in .nanokit/deploy-state.json — git-ignored, per-working-copy, and safely deletable (deleting it just forces one full deploy).
- Only services that deployed successfully are recorded. A failed service keeps its old watermark and is re-included next run.
- The watermark tracks what was actually deployed, not what git shows. Reverting a file after a deploy makes the service dirty again and it redeploys — the remote genuinely still holds the modified content and must be realigned. This is expected, not a bug.
Escape hatches
In increasing bluntness: --all → NANOKIT_DEPLOY_ALL=1 → rm .nanokit/deploy-state.json.
Explicit targets skip the delta entirely
Naming services with -s (or positionally) seeds the scope rather than filtering it; the named set is then expanded with its dependents. Positional arguments and -s flags are unioned.
Every name must exist in the environment's effective config. Unknown names abort before any connection, listing the valid services; mixing service and database targets is rejected. Naming only databases disables scoping altogether (the scope is a set of services), so deploy -s <db> behaves exactly as it always did.
What the scope narrows
The rsync target list and its excludes, the build-project expansion in deploy.prepare, the prune paths, the pre-deploy backup set, the service reconcile targets, and the recorded deployment records.
Under a partial scope, only deploy.prune entries resolving under an in-scope service directory are applied — pruning a shared root artifact mid-partial deploy would wipe the out-of-scope builds.
Git submodules are invisible to the scope
git ls-files reports a submodule as a single gitlink path, so edits inside a submodule do not move the scope. Use --all after changing one.
CI always deploys everything
An ephemeral runner has no watermark and a fresh checkout rewrites every mtime. This is by design.
nkapp up keeps its own (unchanged) fast-reconcile scoping and neither reads nor writes .nanokit/deploy-state.json.
Workflow
1. Load & resolve
Configuration is loaded, environment overrides merged, and all secrets resolved — including the deploy.sshKey field itself, which can be a vault:// URI.
2. Scope resolution
The deploy scope is computed and the scope line printed. An empty partial scope exits here.
3. SSH connection & VPS bootstrap check
Nanokit connects with the resolved credentials. If Docker is missing on the remote host, it offers to bootstrap the VPS: install Docker Engine, configure the UFW firewall (ports 80/tcp, 443/tcp and 443/udp for HTTP/3, plus any per-service firewall: rules), and create the project directory structure.
4. Code synchronization
Project files are synced to /opt/nanokit/projects/<name>/ using the configured method — rsync (default, incremental) or git (remote git pull).
5. prepare
See below — on a VPS target this runs on the remote host.
6. SSL factory reset
If public domains are detected, stale Caddy certificates are purged so ACME challenges start fresh.
7. Service orchestration
The Reconciler runs against the remote Docker daemon to create/update containers.
deploy.prepare — where it runs
The execution host depends on the target
- VPS / Docker-over-SSH targets —
prepareruns on the remote host, inside an isolated runtime container, after the code sync and before the reconcile. - Cloud provider targets (AWS/GCP/Azure/DigitalOcean) —
prepareruns locally (on your machine or the CI runner), because that local build produces the image content that is then pushed to the registry.
The services placeholder
The literal token {{services}} (case-sensitive, no whitespace inside) is replaced everywhere it occurs by the in-scope service names joined with , (no spaces), in config declaration order.
A command without the token runs whole and verbatim — the placeholder is 100% opt-in. Under a full deploy it expands to the environment's complete service list, never to the literal token and never to the empty string.
yaml
deploy:
prepare: npm install --prefer-offline && npx nx run-many -t build --projects=shared,{{services}} --parallel=1It expands to SERVICES ONLY
Projects that are not services but must always be built (shared libraries, the CLI, submodule packages) have to stay explicit in the command. Replacing the whole project list with the placeholder alone drops them and reintroduces unbuilt-dependency deploy failures. Keep an always-build prefix and let the placeholder only add the scoped services.
Per-service buildProject:
Controls what a service contributes to the expansion. It is interpolation only — never hashed — so adding it can never trigger a container recreate.
| Value | Effect |
|---|---|
| omitted | the service name is used |
<string> | that build-tool project name is used instead (for when the build tool's project name differs from the Nanokit service name) |
false | the service has no build project and is excluded from the expansion — for static apps and pure images |
If every in-scope service opts out, the expansion would be empty (a bare --projects= is a broken command), so the CLI substitutes the full build list and logs one line saying why. The prepare is never skipped — it also carries the dependency install.
Ephemeral SSH keys
Nanokit supports in-memory SSH keys resolved from Vault. If the sshKey value contains a raw PEM key (from vault://), Nanokit:
- Writes it to a temporary file with
0600permissions - Uses it for the SSH connection
- Deletes it immediately after deployment
yaml
deploy:
target: root@185.47.172.104
sshKey: "vault://infra/ssh#private_key"Examples
bash
# Deploy to staging using config defaults (change-proportional)
nkapp deploy -e stage
# Force a full deploy
nkapp deploy -e stage --all
# Deploy two specific services (skips delta computation)
nkapp deploy -e stage -s api -s worker
# Deploy to a specific host (override config)
nkapp deploy --ssh root@10.0.0.5 -p 2222
# Rollback to the previous reconciler state
nkapp deploy -e stage --rollback--rollback is narrow
It restores the previous reconciler state (the recorded container image and environment) from a local backup state file. It does not revert code on the remote host, it needs that backup file to exist, and it is never triggered automatically by a failed deploy — you invoke it yourself.
Excluding files from the deploy (.nanokitignore)
Deploys sync the project to the remote with rsync. Beyond the built-in excludes (.nanokit/, node_modules, dist, .next, .nx, .nk-serve, *-live, .nanokit-mock), add your own by placing a .nanokitignore at the project root. One glob per line; lines starting with # are comments.
gitignore
# .nanokitignore — extra paths to keep out of the deploy payload
*.log
fixtures/
docs/internal/The built-in excludes always apply;
.nanokitignoreonly adds to them. It only affects what is shipped on deploy, not what is committed to git.