| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| README.md | 2026-09-17 | 19.1 kB | |
| Version 5.4.0 source code.tar.gz | 2026-09-17 | 600.0 kB | |
| Version 5.4.0 source code.zip | 2026-09-17 | 703.8 kB | |
| Totals: 3 Items | 1.3 MB | 0 | |
[5.4.0] — 2026-09-17
Added — Deploy audit ledger (every deploy, whatever started it)
Until now only cipi deploy and the webhook wrapper wrote deploy banners, into a log the app user owns. A deploy started any other way left no record: cipi/agent running dep inside the app, the panel, or dep deploy typed over SSH. The one thing all of these share is the app's Deployer recipe, so the record is written from there.
/usr/local/bin/cipi-deploy-audit(root, reached through one per-app sudoers rule) is called by the recipe afterdeploy:symlink(published), ondeploy:failed(failed) and afterrollback. Custom apps: afterdeployand on its failure. The hook runs withrunLocally, so the process chain of whoever randepstays visible. A missing helper or rule never fails a deploy.- Root does not take the caller's word for anything. From
/procit reads the releasecurrentpoints at, its commit (Deployer'sREVISION, orhtdocsHEAD for custom apps) and Deployer's ownreleases_logentry. From the process chain it reads the origin:cipi-cli(triggercli,rollback,auto-rollback,sync),panel(cipi deploystarted by www-data),webhook,app-web(PHP-FPM),app-queue,ssh,cronorroot-shell. It also records the operator (audit login uid, set by PAM and not changeable by the user), the SSH client IP and the chain itself. - Claims are kept apart. What only the app can say goes under
claimed, e.g. who pressed deploy in an MCP client.cipi-app-deploynow reads it from the trigger file: the cron moves~/.deploy-triggerto~/.deploy-trigger.runinstead of deleting it, and the file may carrysource,actor,ip,refandrequest_idas JSON orKEY=VALUE. Code that runsdepin-process can set the sameCIPI_DEPLOY_*environment variables. A knownsourcealso names the trigger indeploy.log(trigger=mcp). /var/log/cipi/deploys.jsonl, root-only, one JSON line per event. Each line carries its sequence number and the SHA-256 of the line before it, and is also sent to syslog (cipi-deploy), so forwarding keeps a copy off the server. Repeated calls for a release already recorded are ignored.cipi deploy <app> --audit [--days=90] [--json]— the app's records.cipi compliance deploysnow uses the ledger. It fails when the hash chain is broken. It warns when an app'sdeploy.phplacks the hook (edited by hand), when an app's sudoers lacks the rule, or when Deployer'sreleases_loghas a release the ledger never saw. Only releases since auditing began count (/var/lib/cipi/deploy-audit-since). Evidence:deploy-audit.jsonl,deploy-audit.tsv,deploy-audit-chain.txt,deploy-audit-hooks.txt,deploy-audit-unaudited.tsv, plus the existingdeploys.tsvfrom the banners. Without the ledger (not yet updated) the control warns instead of passing.- Honest about gaps. The chain proves that nothing before the newest record was changed or removed. Root can still rewrite the newest records or the whole file, and the report says so: the syslog copy forwarded off the server is the independent one. The ledger records deploys, not every file change: an app user can still edit
current/over SFTP (covered bycipi scanintegrity) or remove the hook from their owndeploy.php(flagged by compliance and cross-checked againstreleases_log).
Added — Node frontend apps (cipi app create --node) and Node runtimes (cipi node)
Until now a React/Vue SPA could only be a --custom app serving a build committed to git. It had no build step, and client-side routes returned 404 on reload. SSR frameworks had no way to run at all. Node apps are a new app type with the same lifecycle as Laravel ones: releases, rollback, webhook, SSL, basic auth, redirects/proxies, audit ledger.
cipi app create --node=spa|static|ssr [--framework=next|nuxt|sveltekit|astro|remix|vite]with--node-version=22|24,--build=,--start=(SSR),--output=(spa/static) and--health-path=(SSR). Presets fill in each framework's defaults; explicit flags win. Next.js starts withnext start -H 127.0.0.1.- spa: nginx serves
current/<output>, unknown paths fall back to/index.html,assets/ _astro/ _nuxt/ _app/immutable/ static/areimmutable, and*.htmlisno-cache. - static: the same without the fallback (
$uri.html, then a real 404 with/404.html). - ssr: nginx proxies to an upstream (
/etc/nginx/conf.d/cipi-node-<app>.conf) with WebSocket upgrade andX-Forwarded-*headers. - Blue/green deploys (
/usr/local/bin/cipi-node-switch, root, one sudoers rule per app). The recipe calls it beforedeploy:symlink: - the release starts on the idle Supervisor program (
<app>-node-blue/<app>-node-green, each on its own localhost port in 3100–3999, in/etc/supervisor/conf.d/<app>-node.confsocipi-worker restartcannot bounce both); - it must answer below 500 on the health path within 60 s;
- then the upstream moves (
nginx -t, reload) and the old slot stops after a short drain. - A process that exits or never answers is stopped, the old slot keeps serving and the deploy fails before
currentmoves.dep rollbackswitches back the same way. - Only the release path comes from the caller, and it must be a release of that app. Ports, start command, Node version and health path come from
/var/lib/cipi/node/<app>.json, which only root writes. cipi-node-run(app user) reads.envasKEY=VALUEdata and never sources it.PORT/HOSTcannot be overridden,HOST,HOSTNAMEandNITRO_HOSTare forced to 127.0.0.1, and the start command is executed as argv with no shell. Start commands are limited to a Node runner (node npm npx pnpm yarn bun) plus plain arguments.- Recipe (
lib/deployer/node.php): installs dependencies from the lockfile (npm ci,pnpm install --frozen-lockfile,yarn install --immutable|--frozen-lockfile,bun install --frozen-lockfile;npm installwith a warning when there is no lockfile). It then builds withNODE_ENV=productionand telemetry off, and for spa/static checks that<output>/index.htmlexists.shared/.envis a shared file, so frameworks read it at build time and SSR servers at runtime. - Node runtimes —
cipi node install|list|upgrade [--restart]|remove <major>. Official nodejs.org builds (x64/arm64) are checked againstSHASUMS256.txt, unpacked to/opt/cipi/node/v<version>and exposed as/opt/cipi/node/<major>through an atomic symlink, with corepack enabled for pnpm/yarn. Even (LTS) majors only; a major in use cannot be removed. The NodeSource Node 20 used by Laravel asset builds is untouched.cipi ymldeploy.postnpm/node steps use the app's major (from~/.deployer/node.json). - Webhooks without cipi/agent —
/cipi/webhookgoes to/usr/local/share/cipi/webhook.phpthrough an on-demand PHP-FPM pool running as the app user (open_basedir= home + receiver,exec-family functions disabled, 2 MB body, POST only, never behind basic auth). It verifies GitHub and Bitbucket HMAC-SHA256 and GitLab/Azure tokens, deploys only pushes to the app's branch, and writes the pusher, delivery id and commit into the trigger file, which the audit ledger records underclaimed. Git providers get the webhook registered automatically, andcipi git refreshcovers Node apps. - Server-wide Node for Laravel apps too —
cipi node default <major>|system: <major>installs the major if needed and linksnode npm npx corepack pnpm pnpx yarn yarnpkgfrom/opt/cipi/node/<major>/bininto/usr/local/bin, which comes before NodeSource's/usr/binon the PATH of SSH sessions (Deployer), sudo (secure_path,cipi app run) and app.bashrc. Laravel--node-buildasset builds,cipi app run <app> npm …andcipi.ymldeploy.postall use it from their next run.- The links point at the major, not at a patch, so
cipi node upgradeupdates every app on the default. systemremoves only Cipi's links and falls back to the NodeSource package, which is never removed.- A file in
/usr/local/binthat Cipi did not create (a global pnpm, a hand-installed node) is left alone and named in a warning. cipi node listmarks the default. New Node apps start on it unless--node-versionsays otherwise. The default major cannot be removed. Changing it sends anode_defaultnotification.- Pin one Laravel app —
cipi app edit <app> --node-version=24keeps that app on its own major whatever the default is;--node-version=defaultfollows the server again. Itsnode-build.shexports that major's PATH,cipi app runanddeploy.postuse it too, andcipi app showsays whether the app is pinned or on the server default.cipi.ymldeploy.postfrom cron now has/usr/local/binon PATH. - Per-app commands:
cipi node status|restart|logs <app>(restart is blue/green, so no downtime).cipi app edit <app>accepts--build= --start= --output= --health-path= --node-version= --node=spa|static|ssr.cipi app envworks for Node apps.app show/app listshow the mode and slots, andapp deletestops and removes both slots, the upstream and the state. - Not yet:
cipi syncskips Node apps with a message, andcipi app clonerefuses them, as it does custom apps. Databases are not created with the app: usecipi.ymldatabases:orcipi db.
Added — cipi.yml: www redirect, basic auth, redirects, proxies, search
app.www: to-root | from-root | none. The other name of the pair must be inapp.aliaseswhen both are declared, and a declared alias list that drops a name the current redirect needs is refused at plan time instead of failing halfway through the apply. Clearing runs before the aliases are reconciled; setting runs after.app.basic_auth—users:as names or{name, password_hash}, orbasic_auth: false. No password in the repository: a name-only user keeps the password already set on the server (cipi basicauth enable <app> --user=NAME); a user the server does not know blocks the plan.password_hashaccepts only bcrypt (cost 10+) or SHA-512 crypt; apr1/MD5 is refused. Server users not listed are removed.cipi yml generateemits user names only, never hashes.redirect(to,code,keep_path,enabled;enabled: falsealone removes it),redirects[](from,to,code,keep_path) andproxies[](prefix,upstream,strip_prefix,preserve_host,timeout,buffering). A declared list replaces the current one. Every rule goes through the same validation ascipi redirect/cipi proxy(loops, collisions, reserved paths, Reverb, charset), checked against the whole declared set, so a redirect and a proxy on the same prefix collide in the plan. The routes apply as one change: one vhost regeneration, onenginx -t, reverted as a whole if nginx refuses it.- Stricter than the CLI for proxies, because anyone who can commit controls the file: no
--force, so Cipi's own loopback ports (MariaDB, PostgreSQL, Valkey, SSH, Meilisearch, nginx, another app's Octane/Reverb) are always refused. Link-local and0.0.0.0/8upstreams, cloud metadata included, are refused too, and resolved hostnames are checked as well as literal IPs. node:(Node apps):framework,mode,version,build,start,output,health_path, with the same presets and validation ascipi app create --node. It is applied by the deploy itself, not bycipi yml apply. Right after the checkout, the recipe'snode:configrunscipi yml node-sync <app> <release>(root). That command reads the section from that release, updates apps.json, the build script, the blue/green state and~/.deployer/node.json, and the recipe re-reads them. The commit that changes the build or start command is therefore built and started with it.- A mode or output change moves nginx only after
currenthas moved (node:finalize), so nginx never serves a build that does not exist yet. Going fromssrtospa/staticretires the slots; going tossrallocates the ports before the switch. - A Node major that is not installed fails the deploy before the build: installing runtimes stays with root (
cipi node install). - An invalid file leaves the server's settings in place, and the usual
yml_failalert follows from the post-deploy apply. - Only with
cipi yml auto <app> on, which is also what writes the sudo rule fornode-sync: without it the recipe does not even try.cipi yml planlists the changes the next deploy will make.node:on a Laravel app blocks the plan. search: true | false— Meilisearch for Laravel Scout, the same ascipi search enable|disable <app>: a key scoped to<app>-*and theSCOUT_*/MEILISEARCH_*variables in.env. Installing the engine stays with root: the plan is blocked untilcipi search installhas run and Meilisearch is up.falsenever drops indexes, which only root can purge (--purge-indexes). Refused on custom apps.deploy:recipe options — the same set ascipi app deploy-config, from the repository:keep_releases(1–20), the artisan hooks (migrate,optimize,storage_link,queue_restart,horizon_terminate),extra_artisan(validated,tinkerrefused) andsnapshot(the pre-deploy database snapshot). Only declared keys are reconciled; a change regeneratesdeploy.phponce. Refused on custom apps (no recipe); on Node apps onlykeep_releasesandsnapshotapply — the artisan hooks block the plan.app.limits—memory_limit,fpm_max_children,octane_workers,worker_procs, the same ascipi app limitsand reapplied through it (FPM pool, Octane and worker programs regenerated). The bounds are the CLI's, but a value outside them blocks the plan instead of being clamped: nobody is watching an unattended apply.ssl.force_https: true— the HTTP → HTTPS redirect, the same ascipi ssl force. The plan is blocked until the certificate exists (cipi ssl installstays with root). It can only ever be turned on from the file: no cipi command disables the redirect, sofalseagainst an app already forced is refused rather than silently ignored.env.required— names (never values) the app's.envmust carry. A missing or empty variable blocks the plan, so code that expectsSTRIPE_KEYis never deployed against a server that does not have it. Purely a gate: nothing is written.crons:— scheduled commands through the same allowlisted runners asdeploy.post(artisan,npm,composer,php,node, …; no shell, no pipes,%impossible by charset), withevery: 30mor five cron fields, the same grammar as backup profiles. The declared list replaces only the crontab lines the file manages (tagged# cipi-yml); the rest of the app user's crontab — the Laravel scheduler line included — is never touched.artisanentries on a non-Laravel app block the plan.cipi yml generateandcipi yml examplecover the new keys.lib/routes.sh: validation moved into_routes_build_app_redirect,_routes_build_redirectand_routes_build_proxy, shared by the CLI andcipi.yml. CLI behaviour is unchanged.
Fixed
- Old backups were never deleted (e.g. a
1wprofile still holding archives from August). Three causes, all fixed: - The pre-5.1 layout was orphaned. 5.1.0 turned the nightly job into the
defaultprofile (s3://<bucket>/cipi/default/<ts>/) and removed the oldcipi backup prune --weeks=Nline. That line was the only thing prunings3://<bucket>/cipi/<app>/<ts>/, so every archive written before the upgrade stayed forever. The same happened to the archives of a removed profile, and to the database dumps in/var/log/cipi/backups/, wherecipi deploy --snapshotstill writes pre-deploy snapshots.- Every backup run, and
cipi backup prune, now also prunes those orphans (local and S3). They are kept as long as the longest age-based retention of any profile, so nothing goes sooner than a current profile would keep it. With count-only retention (keep: N) there is no age to go by, and orphans are left alone.--dry-runlists them.
- Every backup run, and
- S3 errors were swallowed. Retention listed S3 with
2>/dev/null: a deniedListBucket, a wrong endpoint or a missing CLI made it prune nothing, every night, with no sign. An empty prefix is still not an error. Any other listing or delete failure is now logged, sends a "Cipi backup retention failed" alert (backup_fail) without failing the backup itself, and makescipi backup pruneexit 1 with the reason. - Cron PATH. Root's crontab has no
/usr/local/bin, where the AWS CLI installs;lib/backup.shnow adds it. - "Cipi API updated" was sent every night by the 04:30 soft update, even when Composer changed nothing.
cipi api updatenow compares a fingerprint of the locked packages (name, version, dist/source reference) before and after. Only a real change is logged (API UPDATED: cipi/api A → B) and notified, with the old and newcipi/apiversions. Otherwise it prints "already up to date". cipi yml exampleprintedevery:: command not found(an escaped backtick in the template).cipi.ymlon a custom app:workers.queues,workers.horizon: trueandschedule: trueproduced artisan workers the app cannot run, or stopped the apply halfway. They now block the plan, likeworkers.reverbandsearchalready did._basicauth_set_userremoved users with a regex, so a user nameda.balso removedaxb. It now matches the name exactly, and the temporary file is no longer created world-readable.
Migration
lib/migrations/5.4.0.sh: installs cipi-deploy-audit, adds the sudoers rule per app (validated with visudo, restored if rejected), appends the audit hooks to each deploy.php without touching the rest of the file, switches the trigger cron from rm to mv, and writes the audit start marker.
Node 20 → 22. Node 20 reached end of life in April 2026.
- Fresh installs:
setup.shnow installs Node 22 from NodeSource. - Existing servers whose Node is still 20 (the NodeSource package, or a
cipi node default 20): the migration switches them to Node 22 withcipi node default 22, which downloads and verifies the official build and links it into/usr/local/binahead of/usr/bin. The NodeSource package is not touched, socipi node default systemrestores 20. Laravel asset builds,cipi app run npmanddeploy.postuse 22 from their next run. - Servers already on another major, or with no Node at all, are left as they are.
- Failed download: the server stays on 20, the migration says how to retry (
cipi node default 22) and the update carries on. - A
node_defaultnotification is sent.
Nothing is deployed, reloaded or restarted.