Skip to content

cmd deploy

github-actions[bot] edited this page Oct 12, 2026 · 36 revisions

nself deploy

Deploy the stack to a target environment (local, staging, production).

Synopsis

nself deploy [target] [flags]

Description

nself deploy builds your stack and deploys it to a target environment using a per-service sequenced rolling restart. It chains nself build then restarts services in dependency order, waiting for each service to pass a health check before restarting the next.

The restart order is resolved at deploy time from the project's own compose file (docker compose config --services), never from a fixed guess list — a project's actual service names vary (e.g. object storage is always named minio, never storage; there is no literal plugins service). See Rolling Restart, Service Order and Downtime.

The target environment can be supplied as a positional argument or via --env. The flag takes priority when both are given. A target is local or any environment in the deploy inventory: every environment in .nself/control-plane.yaml, or every NSELF_DEPLOY_HOST_<ENV> variable (so NSELF_DEPLOY_HOST_QA makes qa a target). staging and prod keep working with no inventory, and production is an alias of prod unless the inventory has an environment named production. Only the named environment is deployed or probed; an unknown name is refused with E483 and the list of known environments, and the deploy never falls back to another one.

When NSELF_DEPLOY_HOST_STAGING or NSELF_DEPLOY_HOST_PROD is set, the CLI rsyncs the compose file and env to the remote host, pulls updated images, then runs the rolling restart via SSH. With no host configured for a remote environment the deploy is refused (E483); use nself deploy local to deploy on this machine.

When .nself/control-plane.yaml is present (or --server is passed), nself deploy routes through the topology-aware pipeline (controlplane.Run). The pipeline reads each server's role and SSH capability, skips read-only and hidden servers automatically, and returns a non-zero exit code if the primary app server was skipped. Without that file and without --server, behavior is byte-identical to the legacy single-host path.

Targets accept both short and long forms:

Target Accepts Description
local local Build and rolling-restart on this machine
staging staging Staging environment (uses NSELF_DEPLOY_HOST_STAGING if set)
prod prod, production Production (uses NSELF_DEPLOY_HOST_PROD if set; requires --force or --dry-run)
any other name the environment's name, e.g. qa An environment from the inventory (uses NSELF_DEPLOY_HOST_<ENV> on the single-host path); loads .env, .env.<name>, .env.secrets (the same list nself build uses), never .env.dev or .env.prod, and a deploy to any remote host never reads .env.local (your personal override stays on your machine; nself deploy local still loads it)

An environment named prod or production is production-class and requires --force (or --yes) unless you pass --dry-run. The check runs before every deploy path, including the blue/green canary path. Releases before this change deployed every environment in the inventory whenever a .nself/control-plane.yaml existed, whatever target you named.

A deploy to a remote environment with no host (no inventory entry and no NSELF_DEPLOY_HOST_<ENV>) is refused with E483 before anything is built; it never deploys on the current machine. The blue/green canary flags apply to local only; with a remote environment they are refused. In the control-plane pipeline, a server whose deploy fails makes the command exit non-zero and lists it. With a .nself/control-plane.yaml, a remote deploy first runs the same remote build (no .env.local) and writes the validated env snapshot, then ships both the compose file and .env.<env> to every server; if the build or snapshot fails nothing is probed or sent. A prod-class environment always uses the prod env cascade and is written to .env.prod on the host. The env file pushed to the host is read with the same dotenv reader as the build, so both see the same values. An inventory with two environment names that differ only by case is refused, here and in nself deploy environments.

Access targets and ops profile

nself deploy access grant, revoke, and list accept --env, --tier, and --server. The selectors may be combined and resolve the matching inventory hosts in environment, role, and server order. --host remains available for an explicit single host and cannot be combined with a selector. A selector that matches no hosts is refused before SSH runs. --dry-run on grant or revoke shows each host's resulting change without writing any host. In v1.5 mode, changes to a prod-tier host require --yes or terminal confirmation; an unconfirmed noninteractive command exits 4 (E403). Revoking the last key still requires --force on each host. Selector list JSON has one result object per host, with its env, server, host, entries, and foreign_count. Explicit --host --json keeps its existing JSON array.

nself deploy --profile ops builds the ops compose profile and defaults to environment ops. An explicit target or --env overrides that default. NSELF_DEPLOY_HOST_OPS supplies a single-host target when no inventory file is present. OPS_DEPLOY_HOST remains a v1.5 fallback with a deprecation warning. In v1.5, nself deploy ops and nself ops deploy use the same pipeline path as nself deploy --profile ops. --dry-run prints the profile, target environment, host, and remote path before any build or SSH action.

Environment promotion (v1.5)

nself deploy promote <source> <target> --yes promotes between inventory environments. Tiers must move forward or stay equal: local, local-servers, then prod. Source and target must differ. A prod-tier target requires --yes or terminal confirmation; an unconfirmed noninteractive promotion exits 4 (E403).

--dry-run prints the ordered plan without contacting or changing a host. With --json, it emits one v1 envelope containing the result and planned steps. The steps are resolve, confirm, verify, preflight, source-health, capture, build, compare, snapshot, save, apply, migrate, health, and record. Builds use the target cascade in a scratch directory. Production values never enter the operator's tree.

Compare proves per-platform image digest equality before taking a snapshot or changing the target. It resolves every staged compose fragment with the target's environment files and captures the source using its compose manifest. Each service's platform override selects its manifest; a source platform mismatch is drift. linux/arm64/v8 and linux/arm64 select the same platform. Other variants, including linux/arm/v6 and linux/arm/v7, remain distinct. Drift exits 1 (E490) and names the affected services and lookup failures. Services with a compose build: context are unverifiable: prod refuses them; other tiers warn. The snapshot is created, located by tag, and verified before save or apply. A failed verification exits with E206 without applying anything. After apply, running image identities are inspected again before migration. A changed tag fails promotion and restores files without restoring the database.

Failures after the verified snapshot restore the saved artifacts. The database is restored only if migration started, followed by another health check. Successful rollback exits 2 (E488) naming the failed step. Failed rollback stops immediately with E489, the snapshot tag, backup ID, and a manual restore command. The local .nself/state/promote.json records each host's saved tar and changed or absent files. nself deploy rollback <target> and the v1.5 nself promote rollback shim restore that record when it is the latest change, using the verified backup on its recorded target host. Before a promotion database restore starts, recovery persists db_restore_started; after success it persists DBRestoredAt. If completion cannot be saved or the process stops, a retry refuses with E489, naming the backup and warning that the restore may already have run. Verify the database on the backup host first. If the restore completed (including a restore performed by hand), run nself deploy rollback <target> --db-restore-done to record completion and finish recovery without restoring again. The flag asserts operator verification; it does not run a database restore.

Record rollback reapplies all saved stacks before restoring the database, only when migration started and the backup has not already been restored. An already rolled-back record changes nothing. The project deploy operation lock covers record reload, file reapply, database restore, health, and persistence. For deploy rollback, foreign or superseded promotion records use the v1.5 deploy record; an unusable same-target record is refused with E485 before remote writes. Supersession uses the deployment identity saved by the promotion, rather than timestamp order. On platforms without operation-lock support, rollback follows the deploy guard's unlocked fallback. If promote.json is corrupt, the E485 message names the file and tells you to move it aside; deploy rollback <target> then uses deploy.json. Promotions also update the record used by deploy status and the next deployment.

With no arguments, deploy promote keeps the existing canary flip. In v1.4, its arguments retain their previous behavior and promote keeps its legacy implementation. In v1.5, promote <source> <target> and deploy promote-env <source> <target> delegate to the environment promotion runner with one deprecation warning per process.

Deploy Strategies

Strategy Status Behavior
rolling Default (v1.1.1) Per-service sequenced restart with health-gating. Each service waits up to 60s for service_healthy before the next restarts.
blue-green / canary Available via --canary N (Y17) Zero-downtime: green containers run alongside blue, Nginx shifts N% traffic to green during soak, then promotes to 100%. Requires NSELF_FEATURE_BLUE_GREEN_DEPLOY=true.
preview Not yet implemented Falls back to rolling with an explicit warning.

The --strategy=blue-green and --strategy=canary flags still fall back to rolling for backwards compatibility with existing scripts. Use --canary N to activate the implemented blue/green path.

See cmd-deploy for the full per-strategy behavior spec and downtime expectations.

Blue/Green Canary Deploy (Y17)

Enable with NSELF_FEATURE_BLUE_GREEN_DEPLOY=true (feature flag blue_green_deploy via nself flag list).

How it works

1. Pull new images tagged as green
2. Start green containers (docker compose -p nself-green up -d)
3. Health check green (30s timeout)
4. Nginx upstream: route N% to green (via upstream weights)
5. Canary soak period (default 5 min)
   - Monitor error rate from green containers
   - If error_rate > NSELF_CANARY_ERROR_THRESHOLD (default 1%) → auto-rollback
6. Promote Nginx upstream to 100% green
7. Health check green at 100%
8. Stop and remove blue containers
9. Green becomes blue for the next deploy

Port assignment

Color Port offset Example (Hasura)
Blue 0 (base ports) 8080
Green +100 8180

Examples

# Canary at 10% (requires NSELF_FEATURE_BLUE_GREEN_DEPLOY=true)
nself deploy local --canary 10

# Canary at 25%
nself deploy staging --canary 25 --force

# Skip canary — flip directly to 100% green
nself deploy local --skip-canary

# Promote a canary to 100% after manual review
nself deploy promote

# Rollback to blue in < 5 seconds
nself deploy rollback local

Backward-incompatible migrations

If a pending migration is not safe to run during canary (DROP COLUMN, DROP TABLE, RENAME COLUMN, ALTER COLUMN TYPE, NOT NULL without DEFAULT), the deploy is blocked:

ERROR: Migration 0042_drop_column_old_name.sql is not backward-compatible.
Run with --force-migration to apply (disables canary, full downtime deploy).

Use --force-migration to proceed with a full downtime deploy.

State file

Blue/green state is persisted to .nself/bluegreen/state.json. Run nself deploy status --blue-green to inspect the current active environment and canary traffic split.

Rolling Restart, Service Order and Downtime

The rolling strategy restarts services in dependency order, one docker compose up -d --no-deps <service> at a time, and gates each service before the next one starts. If a service fails its gate, the deploy halts, names the service, and restarts nothing after it.

Manifest and env files. Every compose call uses the same inputs as nself restart: all files listed in .nself/compose-files.txt (the base file, plugin fragments, the user override) as -f, and the env files .env then .nself/compose.env as --env-file (later wins). Plugin services therefore get the variables nself build computed for them. The root .env is no longer a fallback for plugin variables: a plugin variable must be in .nself/compose.env (run nself build).

Health gate by service kind. The kind comes from the resolved config (docker compose config --format json over the manifest):

Service kind Waits for Limit On failure
Declares a healthcheck healthy 60s E251 (health check timeout), service named
One-shot (restart: "no", no healthcheck) a new run that exits 0 60s non-zero exit fails at once with E250; never exiting, or never starting again, is E251
Anything else running, seen as the same run on two polls 2s apart 30s E251, service named

The rows apply top to bottom: a service with restart: "no" and a healthcheck is long-running, so it waits for healthy. For every kind, a container that exited non-zero or is restarting fails the deploy at once with E250 when a poll sees it. After the last service, every gated service is read once more, and the deploy fails with E250 naming any that restarted, is restarting, or exited non-zero since its gate passed. That catches a crash loop that stays up for a few seconds, but it is not a guarantee: a crash after that final re-check is not detected, and the health checks (nself doctor) that follow the roll are the only backstop. A service with no restart line that exited 0 counts as a finished run (Docker's default policy is "no"), so it is done. Containers left behind by docker compose run are ignored: they are not replicas of the service. A service that appears in the restart order but not in the resolved config fails the deploy with E252, checked for every service before the first restart. If Docker cannot be read (daemon down, compose ps fails), the deploy stops with Docker's own message and no service code, because nothing is known about the service; an interrupt (ctrl-c) stops the same way.

What a failed deploy leaves behind. Nothing rolls back. Services before the failed one already run the new config. The failed service is in whatever state up left it (running but not ready, restarting, or exited). Services after it are not touched and keep running the old config. Fix the cause and run nself deploy again: it restarts every service in order.

Init containers that run once and exit never report healthy; the old gate waited for that and aborted real deploys.

The order is derived from the project's own resolved compose file, not a fixed list. Before each restart, nself deploy runs docker compose config --services over the manifest (locally, or over SSH for a remote push target) to get the set of services that actually exist, then orders them:

  1. Core services first, in dependency order, but only the ones actually present: postgres → hasura → auth.
  2. Every other present service next, in the order docker reported them (e.g. minio, nginx, redis, mailpit, ping-api, auth-server, nself-admin — whatever this project's compose actually defines).
  3. Plugin-contributed services last — any service a plugin fragment in the compose manifest adds (and the base file does not define) restarts after everything else.

A service name is never invented. Restarting a name that doesn't exist in this project's compose (the old fixed order included storage and plugins, neither of which is a real compose service name — object storage is always minio) used to abort the deploy with no such service: storage after earlier services had already been recreated, leaving the stack half-restarted. This is why the order can no longer be hardcoded.

Example for a project whose compose defines postgres hasura auth auth-server mailpit minio ping-api nginx redis nself-admin (no storage, no plugins):

Service Restart order Expected downtime
postgres 1st 5–30s (WAL recovery time)
hasura 2nd 0–10s (waits for postgres)
auth 3rd 0–5s
auth-server, mailpit, minio, ping-api, nginx, redis, nself-admin 4th onward, in compose order 0–5s each
(any installed plugin's service) last 0–5s per plugin

Total deploy time scales with the number of services actually present (roughly ~15s each). Total user-visible downtime is lower than the deploy duration because each service continues serving until its replacement becomes healthy.

--dry-run cannot query a live docker compose config --services (nothing has been built or pulled yet), so it prints the resolution rule instead of a concrete list — the real order is only known once the build for this deploy has run.

Use --skip-health-check to bypass the 60s gate for known-slow services like MeiliSearch or Grafana. Set HEALTHCHECK_TIMEOUT_<SERVICE> (e.g. HEALTHCHECK_TIMEOUT_GRAFANA=120s) to extend the per-service timeout.

Custom service env vars in a dry-run

--dry-run also prints, for each declared custom service (CS_1..CS_10), the full list of environment variable names it will receive: the fixed core set (project identity, Postgres, Hasura, Auth connection info) plus whatever CS_N_ENV_PASSTHROUGH and CS_N_ENV add. Values are never printed. This exists so a plain addition to .env.prod doesn't get mistaken for something a custom service will automatically see — it only reaches the service's container if that service's CS_N_ENV_PASSTHROUGH allowlist names it, or CS_N_ENV sets it explicitly. See Config: Custom Services for the full precedence rules.

Remote Push

Set NSELF_DEPLOY_HOST_STAGING or NSELF_DEPLOY_HOST_PROD to a user@host:/remote/path value to enable remote deployments. The CLI:

  1. rsyncs docker-compose.yml and .env.<target> to the remote
  2. Runs docker compose pull on the remote to fetch updated images
  3. Runs the rolling restart on the remote via SSH
export NSELF_DEPLOY_HOST_STAGING=ubuntu@203.0.113.10:/opt/nself-staging
nself deploy staging

SSH key defaults to ~/.ssh/id_ed25519. Override with NSELF_DEPLOY_SSH_KEY.

Agent forwarding is always disabled (-o ForwardAgent=no). ssh, scp and rsync get their options as separate arguments from one builder; no option, key path or remote path is ever parsed by a shell. Before any process starts, the CLI refuses a key path outside [a-zA-Z0-9/_.~-] or starting with -, a remote path with shell metacharacters, a leading - or a .. segment, and any ssh option holding whitespace or a quote (rsync re-splits its -e string on spaces).

Host keys. Which policy a deploy uses depends on what it sends:

Deploy path v1.4 (default) v1.5 (NSELF_V15=1)
Secrets push (.env.<target> copied with scp) strict strict
Deploy that ships an env file (inventory deploy, nself deploy <target> push) StrictHostKeyChecking=accept-new (being closed, see below) strict
Compose file only StrictHostKeyChecking=accept-new strict

Strict means the CLI scans the host's live ED25519 key and continues only when that key is already trusted, either in your ~/.ssh/known_hosts or in the deploy pin file ~/.config/nself/deploy_known_hosts. ssh then runs with StrictHostKeyChecking=yes. Nothing is copied before the check. Only ED25519 host keys are scanned: a host that offers only RSA or ECDSA keys fails the strict check (host key scan ... returned no ED25519 key).

Known gap in v1.4 mode: deploys that ship env files still use accept-new, and any accept-new connection (a probe, a compose-only deploy) records a first-seen key in ~/.ssh/known_hosts. Until that is closed, verify a new host's fingerprint before its first deploy (or pin it with --trust-host-key), or run with NSELF_V15=1.

First contact with a new host. A strict deploy to a host you have never connected to fails with E487 and prints the live fingerprint:

E487 unknown host key SHA256:... for deploy@203.0.113.10; verify it, then run nself env target add <env> <server> --trust-host-key SHA256:...
(or connect once with plain ssh and accept the key into ~/.ssh/known_hosts)

Compare the fingerprint with the one your provider shows (console or ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server), then either pin it with --trust-host-key, or ssh to the host once and accept the key. Before this change the v1.4 secrets push accepted any key on first contact (accept-new).

IP-address hosts are pinned under the alias nself-<ip>-<port> (for example nself-203.0.113.10-22) and ssh gets a matching HostKeyAlias; hosts on a non-default port use nself-<host>-<port>. A pin file holding a line the CLI did not write (a wildcard, @cert-authority or hashed entry) or with group- or world-writable permissions is refused with E487; fix or remove that line and pin again.

  • nself deploy environments, list all environments and per-server SSH capabilities (JSON)
  • nself deploy status [--blue-green] [--server <name>], report current deploy state; --server filters to one server; --blue-green adds slot info
  • nself deploy rollback [target], roll back the last deployment (see below)
  • nself deploy promote, flip Nginx to 100% green after a manual canary review
  • nself deploy logs [target] [--server <name>], tail Docker logs; --server streams logs from a specific server via SSH
  • nself deploy health [target] [--server <name>] [--json], run nself doctor; --server runs doctor over SSH on that server
  • nself deploy check-access (deprecated), verify NSELF_DEPLOY_HOST_* values resolve — use nself env target probe instead

nself deploy environments

Lists every environment defined in .nself/control-plane.yaml (or synthesized from NSELF_DEPLOY_HOST_<TARGET> env vars) and the resolved SSH capability of each server. No key material appears in the output.

nself deploy environments

Output:

{
  "environments": [
    {
      "name": "local",
      "kind": "local",
      "servers": [
        {"name": "local", "role": "app", "capability": "manage"}
      ]
    },
    {
      "name": "staging",
      "kind": "remote",
      "servers": [
        {"name": "staging-app", "role": "app", "capability": "manage"},
        {"name": "staging-lb",  "role": "lb",  "capability": "manage"}
      ]
    },
    {
      "name": "prod",
      "kind": "remote",
      "servers": [
        {"name": "prod-app", "role": "app", "capability": "manage"},
        {"name": "prod-lb",  "role": "lb",  "capability": "read-only", "reason": "ssh: permission denied"}
      ]
    }
  ]
}

Capability values: The reason field appears only when capability is not manage. This command fixes a latent Admin 500 error: the Admin UI calls this endpoint to populate the server-selector before starting a deploy.

nself deploy status --server

When --server <name> is passed and .nself/control-plane.yaml exists, the output includes a per-server capability section alongside the existing state:

{
  "target": "staging",
  "state": "running",
  "servers": [
    {
      "env": "staging",
      "server": "staging-app",
      "role": "app",
      "capability": "manage",
      "latency_ms": 42
    },
    {
      "env": "staging",
      "server": "staging-lb",
      "role": "lb",
      "capability": "manage",
      "latency_ms": 38
    }
  ]
}

nself deploy logs --server

Streams logs from a specific server over SSH:

nself deploy logs staging --server staging-app

The SSH connection uses BatchMode=yes -o ForwardAgent=no -o StrictHostKeyChecking=accept-new. Key material is never logged.

nself deploy health --server

Runs nself doctor on a specific server over SSH and streams the output:

nself deploy health staging --server staging-app
nself deploy health staging --server staging-app --json

With --json, the remote nself doctor --json is called and the structured output is passed through to stdout.

nself deploy check-access (deprecated)

Deprecated. Use nself env target probe for per-server SSH capability resolution. check-access continues to work for backward compatibility. When .nself/control-plane.yaml is present, it probes all servers and emits a deprecation warning, then outputs:

{
  "servers": [
    {"name": "staging-app", "env": "staging", "role": "app", "capability": "manage", "ok": true},
    {"name": "prod-app",    "env": "prod",    "role": "app", "capability": "manage", "ok": true}
  ],
  "all_ok": true
}

Without a control-plane.yaml, it falls back to the legacy NSELF_DEPLOY_HOST_* env-var check with the same JSON schema.

Remote apply in v1.5 mode (NSELF_V15=1)

In v1.5 mode every remote deploy (a control-plane environment, or the single-host NSELF_DEPLOY_HOST_<TARGET> path) applies a full artifact set instead of pushing one compose file. v1.4 mode keeps the push described above, byte for byte. One flow runs per environment:

  1. Target snapshot (read-only). Over ssh, plain file reads on the primary server: the plugins installed in its plugin dir (PLUGIN_DIR or NSELF_PLUGIN_DIR from <remote_path>/.env, else ~/.nself/plugins; name, version and .disabled markers) and the certificate dirs under <remote_path>/ssl/certificates (which of fullchain.pem, privkey.pem, chain.pem exist; the files are never opened). nself is never run on the target. If the local enabled plugin set or a version differs, the deploy stops with E485 (target drift) naming each difference, before anything is written. Every other server of the environment is read again just before its apply and must match the primary.
  2. Scratch build. The project is copied to .nself/deploy-build/<env>/ (rebuilt every deploy, without .nself/, .git, node_modules, ssl/, backups/, .volumes/, logs/ and the old docker-compose.yml). A stub ssl/certificates/<dir>/*.pem tree mirrors the target's names, so generated cert paths are the target's. nself build runs there with the target env cascade. Your own project tree is not written to; the stub files are never shipped. The copy leaves out nginx/ files that carry the build marker (# Generated by nself build): they are stale generated output and the build writes what it owns. Only the nginx files this build generated, plus the unmarked files you name in NSELF_DEPLOY_NGINX_INCLUDE, are shipped. Every other file of your nginx/ tree is still copied (the build may read it) but not shipped, and the deploy output lists it before anything is sent: [not shipped] nginx/<path> (not generated by this build; add it to NSELF_DEPLOY_NGINX_INCLUDE to ship it). A marked file of yours that the build did not write again is listed as [not shipped] nginx/<path> (stale generated output this build did not regenerate). A dry run (--dry-run) stops before the scratch build, so it does not stage and does not print these lines; they appear in a real deploy.
  3. Path map. Every absolute local path in .nself/compose-files.txt, .nself/compose-env-files.txt, the merged compose files and the env files becomes the remote layout: the project root is <remote_path>, plugin fragments live in <remote_path>/.nself/plugins/<slug>/. A shipped file that still names a local root (scratch project, your project, the local plugin dir or your home) fails the deploy before anything is sent.
  4. Deploy lock and pre-deploy backup tar (every tier). The host takes <remote_path>/.nself/deploy.lock (an atomic mkdir; its holder file names pid, host, user, time and id). A second deploy or rollback finds it and refuses, naming the holder. If the holder process is gone, remove the lock by hand: rm <remote_path>/.nself/deploy.lock/holder && rmdir <remote_path>/.nself/deploy.lock. Then, before any sync, <remote_path>/.nself/backups/pre-deploy-<id>.tar.gz (mode 600: it holds env files) takes the compose files, env files, plugin fragments, nginx/ (not conf.d*/, except the nginx/conf.d/<name>.conf files this deploy ships, which the tar also holds when the host has them) and hasura/ that exist, and pre-deploy-<id>.absent lists the artifact paths the host did not have yet (a created conf.d/default.conf included, so a rollback removes it). After a fully successful apply only the newest 5 pre-deploy tars plus this deploy's own are kept; older ones are removed, one [pruned] <name> (old pre-deploy tar) line each. A failed deploy prunes nothing, and a prune error is only a warning. Both paths are recorded in .nself/state/deploy.json (pre_deploy_tars, one per host; pre_deploy_tar for the primary).
  5. Verified backup (tier prod). Right after the tar and before anything is synced or any compose command runs: nself backup create --tag pre-deploy-<id> on the host, the backup is found in nself backup list --format json, and nself backup verify <id> must exit 0. Any failure is E206 and nothing is applied. The id is recorded as backup_id, the host as backup_host. The host needs nself on the ssh PATH.
  6. Artifact sync, in place. rsync -a with no --delete: manifests, merged compose files, fragments, env files (--chmod=F600, mode 600 asserted afterwards), nginx/ and hasura/. The project .env is the host's own file and is never overwritten: it stays in the compose --env-file list, and the sync only creates it (empty, mode 600) when the host has none. The host keeps .nself/deploy-shipped.txt, the list of what nSelf shipped. Before the sync the host removes only files that list names, the new set drops, and that carry the generated by nself marker. A marker-carrying conf nSelf never shipped here (another project's), an unmarked file or anything under conf.d*/ stays. The generated nginx/conf.d/<name>.conf files (today default.conf) ship, and only that one directory: never conf.d-<env>/, never a nested path, and a conf.d file is never pruned. The host decides, from the file alone: a file at that path whose first 5 lines have none starting exactly with # Generated by nself build (case-sensitive; a hand-written file, or another nself writer's such as # Generated by nself ssl add), or that is a symlink, is kept and not overwritten: [kept] nginx/conf.d/<name>.conf (hand-written on the host; not overwritten). A default.conf that is absent on the host is also kept out when any other file under nginx/conf.d*/ or nginx/sites/ already declares a default_server: [kept] nginx/conf.d/default.conf (the host already declares a default_server in <file>; not shipped). Neither case fails the deploy.
  7. nginx -t. In a throwaway docker run --rm container made from the compose nginx service's image (the lock-pinned image when the stack is locked), with that service's new bind mounts read-only at the same container paths. The container is not on the stack network, so every compose service name, container_name and network alias is added as a host entry (127.0.0.1): proxy_pass http://hasura:8080 passes as it does inside the stack. A failure stops the deploy before any reload or start. A stack without an nginx service prints one skip line.
  8. Pull, then recreate preview. docker compose pull, then docker compose ... up -d --dry-run on the host lists the services it would recreate or create, so a moved tag that recreates a service shows up. A service is stateful when it has a volume or a bind mount under <remote_path>/.volumes. On a production-class target (tier prod), a stateful service in the recreate set needs --yes (or a typed yes on a terminal); otherwise the deploy stops with E403 naming the services and nothing is applied. A dry-run line about a recreate or create that cannot be parsed or names no known service also stops a production-class deploy with E403 (--yes does not cover it); elsewhere it is a warning.
  9. Apply. Each service in docker compose config --services order: up -d --no-deps -- <service> and the health gate (healthcheck: healthy within 60 s; one-shot: exit 0; otherwise running within 30 s and the same run a poll later). E250 means the service is broken, E251 that it was too slow. After the last service every gated service is read once more, a running nginx is reloaded (nginx -s reload, only after the throwaway nginx -t passed), and the host runs the same nself db hasura metadata apply as the v1.4 push (strict on staging and production-class targets, a warning elsewhere; NSELF_HASURA_METADATA_STRICT overrides).

Limit: build contexts. The artifact set does not ship build contexts. Before anything is written, a service with a build: context that is not already a directory on the host stops the deploy with E485 (build context not shippable) naming the services. Publish an image for the service, or place its context on the host.

Limits.

  • Only generated nginx/conf.d/<name>.conf files ship from conf.d (see step 6); conf.d-<env>/, nested paths and unmarked files never do. api-docs.conf is a site conf under nginx/sites.
  • A project fronted by an external nginx (NGINX_FRONTED_BY) is built in the scratch dir under a fronting-stack mapping (.nself/deploy-build/<env>/<stack>/backend), and the fronting stack's generated nginx/sites are staged for path.Dir(remote_path). The apply cannot ship them yet, so it stops with E485 after listing them ([staged, not shipped] ...); nothing is sent. Deploy such a project with compat v1.4 (unset NSELF_V15) meanwhile. A remote_path that is not directly under a directory named like NGINX_FRONTED_BY is refused with E485 as well.
  • A deploy refused at E403 (unconfirmed stateful recreate) is restored, but the images the pull fetched stay on the host.
  • A symlinked directory under the project dir is followed only when its real path is inside the real remote_path. A link that resolves outside it (for example nginx/ pointing at a shared dir next to the project, or a symlinked .env.prod) stops the deploy with E485 (symlink outside remote_path) before the lock, the tar and any sync. Move the shared dir inside the project dir. A link that stays inside is written through, and the deploy prints a [notice] line with the real path. The sync never changes the mode of a directory that already exists.
  • A nginx/ file the set ships is never overwritten when it already exists on the host without the nself generated marker, the last recorded deploy did not ship it and the host's own .nself/deploy-shipped.txt does not list it: the deploy stops with E485 (unmarked file would be overwritten) before the lock, naming the path. A first deploy onto a host with hand-written nginx files stops at the first collision; move or delete the file, then deploy again.
  • The sync runs one rsync per destination directory over bare file names, so no directory entry is transferred and it works with any rsync as sender or receiver. Proven operator-side rsync versions: Apple's 2.6.9-compatible rsync (CI on macOS 14, also openrsync) and 3.x (the Linux sim). A Windows operator's compose files may hold C:\... paths; the path map rewrites them (any separator spelling, drive letters case-insensitive) and refuses a leftover.
  • Remote steps are non-interactive (BatchMode=yes, ConnectTimeout=10): use ssh-agent or an unencrypted deploy key. A password or passphrase prompt would hang a step that runs in its own process group.
  • The prune (generated files the new set dropped) is driven by the last recorded deploy in .nself/state/deploy.json; the host's deploy-shipped.txt can only narrow it. With no record nothing is pruned.

If any step from the sync to the preview fails, no container has been touched yet. The files this deploy shipped are put back from the pre-deploy tar in place, the ones it created are removed, and the host is left as it was. Which paths those are comes from this process (and, for a rollback, from deploy.json), never from files read back from the host; the host's own .env and anything the deploy did not ship are never restored or removed. Directories that already exist keep their mode and owner. A failure after the first up keeps the new files and the error names the tar and nself deploy rollback <target>. A deploy that was refused or restored cleanly leaves .nself/state/deploy.json byte for byte as it was.

Ctrl-C. SIGINT, SIGTERM and SIGHUP (a closed terminal or ssh session) during a v1.5 apply or rollback cancel the step in flight (ssh and rsync run in their own process group, so the terminal does not kill them behind the tool's back). The step in flight is not killed: it gets up to two minutes to finish, so its remote script is not still running when the restore starts. A second signal while the restore runs prints one line saying so and does not kill it. Ctrl-C at the Type yes prompt aborts the command at once. Before the first up, the tar is then restored (on a fresh context, at most 5 minutes) and the deploy lock is released; after the first up the new files stay and the error names the rollback. This is tested with real signals sent to the apply and to the rollback. SIGKILL, a lost connection or a powered-off laptop are not covered: the lock stays, and the error for the next deploy says how to remove it.

For v1.5 environment promotion, Ctrl-C cancels the current step and runs compensation on a fresh context, returning E488 when recovery succeeds. A provisional .nself/state/promote-attempt.json keeps snapshot and pre-sync tar evidence until a host keeps changes, when it atomically becomes the promote.json authority. A failed attempt with no changed hosts preserves the previous live promotion. Interrupted records can be recovered with deploy rollback <target>; the database is restored only if migration started and it has not already been restored. If the final record write fails after health succeeds, E489 says the promotion is live and names the snapshot, backup and pre-deploy tars. It leaves the provisional record available and does not undo the healthy promotion.

Record rollback checks each recorded remote path, backup tar location, and the host's applied compose manifest identity before changing any host. A changed path or artifact identity refuses with E485. Restoring a production-class database requires deploy rollback <target> --yes or terminal confirmation; without either, non-interactive use returns E403 (exit 4). The promote rollback --yes shim uses the same confirmation rule in v1.5. Recovery checkpoints completed file and database restores. After a transient DB restore failure, retry accepts the restored prior manifest (including absence) and skips file restoration. An interrupted apply without matching tar evidence refuses with E485 and names the verified backup for operator recovery.

All ssh and rsync commands use the strict host-key policy; an unknown key is E487.

Rollback in v1.5 mode

nself deploy rollback <target> reads .nself/state/deploy.json. When it holds a pre_deploy_tar for the target, it restores the tar on every host in pre_deploy_tars in place: extract to a temp dir under .nself/backups/, rsync -ac back only the files the record lists as shipped (no directory is moved or re-moded, so a bind-mounted nginx/ keeps its inode), and remove only the listed files that did not exist before. On backup_host it then runs nself backup restore <backup_id> --yes when a verified backup was taken (tier prod), and re-applies the restored compose set with the same pull, per-service up, health gate, nginx -t, nginx reload and hasura metadata apply. The database restore returns the data to the moment of the backup: writes made since then, while the new containers ran, are lost.

The database is restored at most once per record: db_restored_at is saved right after nself backup restore succeeds, before the re-apply, so a retry of a rollback that failed later (on any host) skips it and redoes only files and services. The backup_host is rolled back first. If it kept no changes of the deploy (so it has no tar), the database is not restored: the rollback prints the backup id and the manual nself backup restore <id> --yes command and keeps backup_id in the record. backup_id is recorded only once a server ran up. If that command itself fails midway the marker is not set and a retry runs it again. A record already rolled back is refused. The record's success covers every server of the env, with each server's outcome in host_outcomes. Without such a record the older promote rollback below runs.

Rollback

nself deploy rollback is wired to the last nself promote tag. Every nself promote <src> <target> call creates a backup snapshot tagged pre-promote-<target>-<unix> and writes a promotion record to .nself/promotions/<id>.json. Rollback reads the most recent record and restores the env, migration state, and Hasura metadata from that snapshot.

Rollback does not roll back container images. If you need an older image, pull it manually (docker pull nself/...:v1.0.8) before running rollback.

# After a failed or unwanted production promotion:
nself deploy rollback prod
# Finds last pre-promote-prod-* backup tag
# Runs: nself backup restore pre-promote-prod-<ts>
# Prints: Rollback for prod completed — prior promote state restored

When no promote history exists:

Error: rollback failed: find backup: no promotion records found
Run 'nself promote staging' before a deploy to create a rollback point.

When the last promote tag is older than 7 days, rollback prompts for confirmation. A stale rollback may re-apply migrations that no longer match the current code. Review the diff shown before confirming.

Requires: nself promote must have run at least once against the target environment to create a rollback point. On a fresh production host, run nself promote staging prod before the first nself deploy production to ensure a rollback baseline exists.

Health Checks

After each deploy (unless --skip-health is set), the CLI calls nself doctor and checks the output. If any service is unhealthy or has not started within 60s, the deploy result is failed and the specific service name is reported:

  [running] Health checks (calling nself health)
  [failed] Health check failed (service: hasura). Run 'nself doctor --verbose' for details.

Use --skip-health as a break-glass escape hatch. A visible warning is always emitted when used.

Output format

When --json is set, the command writes a structured result:

{
  "target": "staging",
  "strategy": "rolling",
  "steps": [
    {"name": "Build images", "status": "done"},
    {"name": "Restart postgres", "status": "done"},
    {"name": "Restart hasura", "status": "done"},
    {"name": "Restart auth", "status": "done"},
    {"name": "Restart minio", "status": "done"},
    {"name": "Restart nginx", "status": "done"},
    {"name": "Health checks", "status": "done"}
  ],
  "durationMs": 78234,
  "success": true
}

The "Restart <service>" step names shown above are an example, not a fixed list — they mirror whatever this project's own compose file actually defines, per Rolling Restart, Service Order and Downtime.

Human output uses the same step vocabulary (done, failed, skipped, running, pending, unhealthy) inside [...] brackets so the Admin UI's deploy API route can parse it without a separate protocol.

Environment variables

Var Default Purpose
NSELF_DEPLOY_HOST_STAGING — SSH/rsync target for staging: user@host:/path
NSELF_DEPLOY_HOST_PROD — SSH/rsync target for production: user@host:/path
NSELF_DEPLOY_HOST — Generic remote host used when target-specific vars are unset: user@host:/path
NSELF_DEPLOY_USER — SSH user for remote deployments (used when host is specified without a user prefix)
NSELF_DEPLOY_KEY_PATH ~/.ssh/id_ed25519 SSH private key path for remote deployments (alias: NSELF_DEPLOY_SSH_KEY)
STAGING_DEPLOY_HOST — Fallback alias for NSELF_DEPLOY_HOST_STAGING
PROD_DEPLOY_HOST — Fallback alias for NSELF_DEPLOY_HOST_PROD
NSELF_DEPLOY_SSH_KEY ~/.ssh/id_ed25519 SSH key path (superseded by NSELF_DEPLOY_KEY_PATH; both accepted)
HEALTHCHECK_TIMEOUT_<SERVICE> 60s Per-service health-check timeout
NSELF_FEATURE_BLUE_GREEN_DEPLOY false Enable blue/green canary path (Y17). Set to true to activate.
NSELF_DEPLOY_STRATEGY canary Deploy strategy when blue/green is active: canary, blue-green, direct
NSELF_CANARY_PERCENT 10 Initial canary traffic % (used when --canary is not passed)
NSELF_CANARY_SOAK_MINUTES 5 Soak duration before auto-promote
NSELF_CANARY_ERROR_THRESHOLD 1.0 Error rate % that triggers auto-rollback
NSELF_DEPLOY_HEALTH_TIMEOUT 30 Seconds to wait for green container health
NSELF_BLUE_PORT_OFFSET 0 Port offset for blue containers
NSELF_GREEN_PORT_OFFSET 100 Port offset for green containers
NSELF_DEPLOY_NGINX_INCLUDE — v1.5 remote deploy: comma-separated globs relative to nginx/ (sites/hand.conf,includes/*.conf; path.Match per path segment) naming unmarked nginx files to ship besides the ones the build generated. Read from the target's env cascade. Absolute entries, .. and anything under conf.d are refused.
NSELF_DEPLOY_ENV production Deploy target environment set by nself deploy after resolving --env / positional argument. Values: local, staging, production. Exposed for subprocesses and plugins.

A remote environment needs a host; with none the deploy is refused (E483). Multi-region orchestration is available via the dedicated nself region command (list, add, status, promote).

Maintenance Banner

--maintenance-banner is deferred to a future release (DEP-07). To show a maintenance page manually, configure an nginx static page via nginx/conf.d/.

Safety

  • Production deploys without --force are rejected with a clear message
  • Use --dry-run first to preview steps on any target
  • Agent forwarding is disabled by default for all SSH connections (ForwardAgent=no)
  • SSH connections use BatchMode=yes StrictHostKeyChecking=accept-new
  • SSH keys are never logged or included in command output
  • --rollback validates the promote tag's env against current code before applying; drift causes an explicit error rather than a silent misapply
  • Primary-server skip gate: when the topology pipeline detects that the primary app server has read-only capability and was skipped, the command exits non-zero even if other servers succeeded. Use nself deploy environments to diagnose capability issues before deploying.
  • Inline secrets in --host or --key-ref flags are rejected with an error

Cross-references

  • cmd-build, generates docker-compose.yml and nginx configs
  • cmd-start, boot the stack with health checks
  • cmd-promote, promote env-to-env with rollback support
  • cmd-env, probe SSH capability and manage per-server access (nself deploy check-access replacement)
  • cmd-deploy, full per-strategy spec and downtime expectations
  • Home

Flags

Name Shorthand Type Default Env Escalates to JSON override Description
--canary int 0 Start a canary deploy at N%% traffic to green (0 = full flip)
--dry-run bool false Preview the deploy without executing
--env string "" Target environment: local, or any inventory environment such as staging, prod or qa (overrides positional arg; required env vars: NSELF_DEPLOY_HOST, NSELF_DEPLOY_USER, NSELF_DEPLOY_KEY_PATH)
--exclude-frontends bool false Exclude frontend apps from the deploy
--follow bool false Stream container logs after deploy until Ctrl-C (staging/prod only)
--force bool false Skip confirmation prompts (prod requires --confirm or --force)
--force-migration bool false Force deploy even with backward-incompatible migrations (disables canary)
--include-frontends bool false Include frontend apps in the deploy
--json bool false Emit JSON output
--profile string full Compose build profile: full or ops
--rolling bool false Alias for --strategy=rolling
--server string "" Deploy to a specific server only (name from control-plane inventory)
--skip-canary bool false Skip canary phase and flip directly to 100% green
--skip-health bool false Skip post-deploy health checks
--strategy string rolling Deploy strategy: rolling|blue-green|canary|preview
--yes bool false Skip prod confirmation prompt (alias for --force)
--help -h bool false Show help

Subcommands

Name Description
access Manage SSH key access on an already-deployed server
check-access Verify access to configured deploy targets (deprecated: use 'nself env target probe')
environments List configured deploy environments and server capabilities
health Run health checks against a target deployment
logs Show deployment logs for a target
ops Deploy with the ops profile to the ops server
promote Promote a canary or an environment
promote-env Promote one environment to another (e.g. staging to prod)
rollback Roll back the last deployment for a target
security Apply server security
status Show deployment status for a target
targets List deploy targets without connecting to them
web Build web apps locally and deploy prebuilt output to Vercel

Contract

  • Side effect: remote
  • Output: document
  • JSON: legacy
  • Exit codes: none declared

Examples

# Local build + rolling restart (positional form)
nself deploy local

# Local build + rolling restart (flag form)
nself deploy --env local

# List all environments and per-server SSH capabilities
nself deploy environments

# Staging dry-run — shows what would happen without executing
nself deploy staging --dry-run

# Staging dry-run with topology plan (when control-plane.yaml exists)
nself deploy staging --dry-run --server staging-app

# Staging dry-run using --env flag
nself deploy --env staging --dry-run

# Deploy to a single server only
nself deploy staging --server staging-app

# Staging deploy with JSON output
nself deploy staging --json

# Production deploy (rolling)
nself deploy production --force

# Production deploy using --env, skipping the confirmation prompt via --yes
nself deploy --env prod --yes

# Production deploy and stream logs until Ctrl-C
nself deploy --env prod --force --follow

# Production deploy with explicit strategy (blue-green falls back to rolling with warning)
nself deploy production --strategy=blue-green --force

# Roll back to the last promoted state
nself deploy rollback prod

# Per-server status (requires control-plane.yaml)
nself deploy status --server staging-app

# Stream logs from a specific server
nself deploy logs staging --server staging-app

# Run doctor on a specific server
nself deploy health staging --server staging-app --json

# Check SSH access (deprecated — use 'nself env target probe' instead)
nself deploy check-access

See Also

← Commands | Home →

Home


Getting Started


Commands


Features


Configuration


Plugins (87 + 10 monitoring)

Free (25)
Pro (62)
Planned (26)
  • plugin-audit
  • plugin-blog
  • plugin-checkout
  • plugin-commerce
  • plugin-drm
  • plugin-export
  • plugin-flow
  • plugin-import
  • plugin-ldap
  • plugin-mailgun
  • plugin-media
  • plugin-oauth-providers
  • plugin-pages
  • plugin-postmark
  • plugin-rate-limit
  • plugin-reports
  • plugin-saml
  • plugin-scheduler
  • plugin-sendgrid
  • plugin-sso
  • plugin-subscription
  • plugin-thumb
  • plugin-transcoder
  • plugin-twilio
  • plugin-waf
  • plugin-watermark

Guides


Architecture


Reference


Machine interface


Licensing


Security


Brand


Operations


Contributing


Admin


Changelog


All commands (21)

Clone this wiki locally