cipi php

PHP 8.5 is pre-installed during setup. Additional versions can be added at any time; since v4.5.12 Cipi probes APT sources per Ubuntu codename. Since v5.6.3 the order is packages.sury.org first (24.04 and 26.04), then the ondrej/php PPA, then Ubuntu main as a last resort (co-installable 8.3–8.5 where the chosen repo supports it). Same maintainer and packages, but sury.org publishes first: on the 2026-10-21 security release it had the fixed builds for 24.04 while the PPA was still building them. Migration 5.6.3 moves existing servers off the PPA, removing it only once sury.org is in place.

Since v4.5.4 Cipi bundles Deployer 8, which requires PHP ≥ 8.3. cipi php install, cipi php switch, cipi app create and cipi app edit therefore accept only 8.3, 8.4 and 8.5. Legacy versions (7.4–8.2) remain detectable and removable, so you can still clean up a pre-4.5.4 server with cipi php remove <old-version>.
bash
$ cipi php list              # installed versions, status, patch version, security state, system default
$ cipi php install 8.4       # install an additional PHP version (8.3–8.5)
$ cipi php switch 8.4        # set system default (root/cipi, API pool)
$ cipi php remove 8.1        # remove a version, incl. legacy (blocks if default or apps use it)
$ cipi php upgrade           # apply security patches, then report the patch level (exit 1 while vulnerable)

Security patch upgrades

Since v4.7.13, PHP packages are excluded from unattended-upgrades and managed by Cipi instead. cipi php upgrade runs apt-get update and --only-upgrade on every installed php* and libphp* package, then restarts the affected PHP-FPM pools. When SMTP is configured and packages were upgraded, Cipi sends an email (php_upgrade trigger; disable with cipi notifications disable php_upgrade).

The check runs automatically every day at 03:30 via root crontab (wrapped by cipi-cron-notify). Log: /var/log/cipi/php-upgrade.log. With nothing to upgrade, a run costs one apt-get update and restarts nothing. Migration 4.7.13 added it as a weekly job (Sunday 03:30); since v5.6.3 it is daily, and existing servers move to the daily schedule on cipi self-update (migration 5.6.3). A schedule changed by hand is left as it is.

Patch level report (v5.6.3+)

After upgrading, cipi php upgrade prints one line per installed PHP version, compared with the oldest release that carries every security fix Cipi knows about (8.5.12, 8.4.27, 8.3.36, 8.2.35 as of the PHP security release of 2026-10-21):

  • patched — at or past that release.
  • VULNERABLE — the fix exists but this server does not have it, typically because the repository has not published it yet. Run cipi php upgrade again later.
  • distro — a build from the Ubuntu archive, which backports fixes without changing the version number; check apt changelog php8.3-fpm.
  • END OF LIFE — PHP 8.1 and older get no fixes from the PHP project any more. Cipi installs 8.3 and later only, so this affects servers set up before 4.5.4; move their apps with cipi app edit <app> --php=8.5. PHP 8.2 security support ends on 2026-12-31, and the report says so.

While a version is VULNERABLE the command exits 1, so the nightly cron mails a failure through cipi-cron-notify until the fix is in.

Octane apps are checked too. FrankenPHP runs on the PHP built into the binary that Octane downloads into the release, and apt never updates that binary. cipi php upgrade runs current/frankenphp -v as the app user and flags any build on a PHP older than the fixed release. To fix one, rebuild the binary as the app user and restart the worker:

bash
# as the app user, in /home/<app>/current
$ php artisan octane:install --server=frankenphp

# as root
$ supervisorctl restart <app>-octane

cipi php list shows the same patch version and security state for each version; cipi php list --json adds patch, security (patched, vulnerable, distro, eol or unknown) and security_floor to every entry.

FPM pool hardening (v5.6.3+)

Every PHP-FPM pool Cipi writes — app pools, Node webhook pools, and the panel API and GUI pools — turns off the two confirmed attack paths of CVE-2026-109619 (PHP security advisory of 2026-10-21, with CVE-2026-109618: unauthenticated remote code execution in every SAPI that serves web requests):

ini
php_admin_flag[session.upload_progress.enabled] = off
php_admin_flag[fatal_error_backtraces] = off   ; PHP 8.5 only — the setting does not exist earlier

They are set as php_admin_flag, so they apply before the request is parsed and .user.ini or ini_set() cannot turn them back on. The PHP project says only the patch release is a complete fix; the flags narrow the attack until it is installed. Migration 5.6.3 renders the app and Node webhook pools again, edits the cipi-api and cipi-gui pools in place, keeps the previous pools in /var/lib/cipi/fpm-pool-backup-5.6.3/ (a version whose new pools fail php-fpm -t gets the old ones back), runs cipi php upgrade and restarts every PHP-FPM service — a restart, not a reload, because a stale worker is what CVE-2026-109619 exploits.

Octane (FrankenPHP) hardening (v5.7.1+)

FrankenPHP runs the PHP built into its own binary and never reads an FPM pool. Every Octane app therefore loads the same two settings from /etc/php/cipi-octane.d/99-cipi-hardening.ini: its <app>-octane Supervisor program sets PHP_INI_SCAN_DIR=":/etc/php/cipi-octane.d". The leading : keeps PHP's compiled-in scan directory, so the php that starts Octane still loads its conf.d, and Octane passes the variable on to FrankenPHP.

ini
session.upload_progress.enabled = 0
fatal_error_backtraces = 0   ; ignored by PHP below 8.5

The settings load at startup, so ini_set() cannot turn upload progress back on. This also covers the PHP advisory of 2026-10-23 (CVE-2026-115560, no fixed release yet), whose only mitigation is session.upload_progress.enabled=0. Migration 5.7.1 writes the file, renders every Octane program again (previous files in /var/lib/cipi/supervisor-backup-5.7.1/, put back if Supervisor refuses the new ones) and restarts the running ones.

Optional, not a default: the advisory also suggests a lower body limit and an unlimited memory limit for servers that cannot update. Neither is applied by Cipi — a 32M body limit would break uploads that work today, and an unlimited memory limit trades this bug for runaway workers. Both are available through cipi ini set, server-wide or per app with --app=<app>:

bash
# lower body limit: upload_max_filesize first, then post_max_size
$ cipi ini set upload_max_filesize=30M [--app=<app>]
$ cipi ini set post_max_size=32M [--app=<app>]

# unlimited memory_limit — only with a systemd MemoryMax= cap on the phpX.Y-fpm service
$ cipi ini set memory_limit=-1 [--app=<app>]

Cap the service with a systemd MemoryMax= on phpX.Y-fpm (for example systemctl edit php8.5-fpm). Do not use RLIMIT_AS or RLIMIT_DATA for this: the advisory warns that they can trigger the allocation failure behind the bug.

PHP is not the only package Cipi keeps out of unattended-upgrades. Since v5.2.3, nginx, MariaDB, PostgreSQL and Valkey have their own operator-triggered equivalent — see manual stack upgrades. Unlike PHP, those are never scheduled.

System default vs app-specific PHP

The system default is the PHP version used by the root and cipi users, the Cipi API FPM pool, and the API queue worker. Use cipi php switch <ver> to change it. The command migrates the API pool (and the GUI FPM pool when installed), recreates panel sockets, and restarts the API worker. Since 5.0.9+, PUT /api/php/default wraps this via the panel API (php-manage ability). Since 5.0.10–5.0.12, switch tolerates partial update-alternatives success, remounts read-only roots when needed, and keeps API/GUI FPM pools on the target version to avoid nginx 502.

REST: GET /api/php, POST /api/php/install, DELETE /api/php/{version}, PUT /api/php/default (API 1.15.0+ / Cipi 5.0.6+). CLI JSON: cipi php list --json (since 5.0.6).

Each app has its own PHP version (set at app create or via cipi app edit --php=). Deployer, Composer, crontab deploy triggers, and cipi sync import always run with the app's configured PHP — never the system default.

To switch an existing app to a different version:

bash
$ cipi app edit myapp --php=8.5

This hot-swaps the PHP version with zero downtime: updates the FPM pool, Nginx socket, Supervisor workers, crontab, Deployer config, and .env in one atomic operation.

cipi ini

Available since v5.1.0. A guided way to change PHP settings without hunting for the right php.ini. Every server-wide change reaches both SAPIs — PHP-FPM and CLI — so what your web requests see is also what queue workers, artisan and cron see.

bash
$ cipi ini list                                # effective values and which layer set them
$ cipi ini list --app=shop                     # as seen by one app
$ cipi ini get memory_limit                    # one setting
$ cipi ini set upload_max_filesize=50M         # server-wide (FPM + CLI)
$ cipi ini set memory_limit=512M --app=shop    # for one app only
$ cipi ini unset memory_limit --app=shop       # fall back to the wider value
$ cipi ini reset [--app=shop]                  # back to Cipi defaults
$ cipi ini keys                                # what can be set, and what cannot

A 5.1.3 hotfix stopped cipi ini list from dying on the first setting after printing the table header (_INI_SOURCE: unbound variable). No migration: cipi self-update copies the new lib/ini.sh.

What it does for you

  • It raises the settings that would silently cap yours. Setting upload_max_filesize also raises post_max_size and memory_limit when they would otherwise limit it, and Nginx's client_max_body_size is flagged when it would.
  • It refuses what should not be set this way. Settable keys are an explicit whitelist — see cipi ini keys. open_basedir, auto_prepend_file, extension and friends are refused by name, with the reason, because Cipi manages them as part of app isolation.
  • Per-app overrides win. --app=<app> writes into that app's FPM pool, which outranks the server-wide file — for that app only.

Why this needed fixing

Before 5.1.0 every FPM pool hardcoded upload_max_filesize, post_max_size and max_execution_time, and pool values outrank conf.d — so a server-wide php.ini change could not reach a single app. On top of that, Cipi wrote 99-cipi.ini for FPM only, leaving the CLI SAPI (queue workers, artisan, cron) on the PHP package defaults.

In 5.1.0 pools carry only what is genuinely per-app (open_basedir, the error log, explicit overrides) and inherit everything else; both SAPIs get their file; and migration 5.1.0 backfills the CLI 99-cipi.ini and rewrites existing pools on update.

Each change fires the ini_set notification trigger, so a PHP setting never changes on a shared server without a trace. An app can also carry its PHP version and per-app settings in its repository — see cipi.yml.

cipi node

Available since v5.4.0. Node runtimes for Node apps, and a server-wide Node for Laravel asset builds too. Official nodejs.org builds (x64 and arm64) are checked against SHASUMS256.txt, unpacked to /opt/cipi/node/v<version> and exposed as /opt/cipi/node/<major> through an atomic symlink, with corepack enabled for pnpm and yarn. Even (LTS) majors only.

bash
$ cipi node install 24             # official build, checksum-verified (default major: 22)
$ cipi node list                   # installed majors, the default, and the apps on each
$ cipi node upgrade 22 --restart   # latest patch; SSR apps restart blue/green
$ cipi node remove 20              # refused while an app or the default uses it
$ cipi node default 22             # server-wide Node for Laravel builds, app run, deploy.post
$ cipi node default system         # back to the NodeSource package

# per app
$ cipi node status <app>
$ cipi node restart <app>          # zero-downtime (SSR)
$ cipi node logs <app> [--lines=100]

A server-wide Node for Laravel apps

cipi node default <major> installs the major if needed and links node npm npx corepack pnpm pnpx yarn yarnpkg from /opt/cipi/node/<major>/bin into /usr/local/bin, which comes before NodeSource's /usr/bin on the PATH of SSH sessions (Deployer), sudo (secure_path, cipi app run) and each app's .bashrc. Laravel --node-build asset builds, cipi app run <app> npm … and cipi.yml deploy.post all use it from their next run.

  • The links point at the major, not at a patch, so cipi node upgrade updates every app on the default.
  • system removes only Cipi's links and falls back to the NodeSource package, which is never removed.
  • A file in /usr/local/bin that Cipi did not create — a global pnpm, a hand-installed node — is left alone and named in a warning.
  • New Node apps start on the default unless --node-version says otherwise. The default major cannot be removed. Changing it sends the node_default notification.
  • One Laravel app can keep its own major with cipi app edit <app> --node-version=24; --node-version=default follows the server again.
Node 20 → 22. Node 20 reached end of life in April 2026. Fresh installs get Node 22 from NodeSource. On update, migration 5.4.0 switches servers still on 20 to cipi node default 22; the NodeSource package stays, so cipi node default system restores 20. Servers already on another major, or with no Node, are left alone.

The panel API (1.31+, Cipi 5.4.1+) can list runtimes and read and restart Node apps (node list|status|restart). Installing, upgrading, removing runtimes and changing the default stay CLI-root: installing software on the server is not something a web panel should be able to do.

cipi db

Cipi creates a dedicated database for each Laravel app automatically during app create. MariaDB (port 3306) is the native default; since v4.8.0 you can also install optional PostgreSQL (port 5432) and choose an engine per database or app. Custom apps do not get a database — use cipi db create --name=<app> if you need one (e.g. for WordPress or another CMS). After setup, a ready-to-use connection URL is displayed (MariaDB: mariadb+ssh://), combining SSH credentials, server IP, and database information for GUI clients like TablePlus, DBeaver, or Sequel Pro.

Engines (v4.8.0+)

bash
$ cipi db install pgsql              # install optional PostgreSQL
$ cipi db uninstall pgsql|mariadb    # remove a non-default engine (destroys data)
$ cipi db default mariadb|pgsql      # server-wide default when --engine is omitted
$ cipi db engines                    # installed engines, ports, and default

Database lifecycle

bash
$ cipi db create                              # interactive: engine menu, then the name
$ cipi db create --name=analytics             # engine menu on a terminal; default engine in a script
$ cipi db create --name=analytics --engine=pgsql
$ cipi db list [--engine=mariadb|pgsql]       # list databases with sizes
$ cipi db backup myapp [--engine=…]
$ cipi db restore myapp backup.sql.gz [--engine=…]
$ cipi db password myapp [--engine=…]         # regenerate password
$ cipi db delete analytics [--engine=…]

Since v5.6.0, with both engines installed and no --engine, cipi db create on a terminal opens a menu — MariaDB or PostgreSQL, the server default (cipi db default) pre-selected, Enter to take it. A script, or a server with one engine, gets the default without a prompt.

Since v5.6.0, cipi db restore stops at the first failed statement on both engines and says what failed (PostgreSQL restore: ERROR: relation "users" already exists), exiting 1 — before, a PostgreSQL restore could leave the database empty and still print restored. A .gz that does not uncompress fails too. A dump taken on a newer PostgreSQL (e.g. pg_dump 17 into PostgreSQL 16 on Ubuntu 24.04) still restores: preamble SET lines for parameters this server does not have are dropped. The same applies to cipi app clone --with-db and cipi sync import.

Since v5.6.2, a failed PostgreSQL restore no longer empties the database: wipe and import run in one transaction and COMMIT is sent only after the whole dump has been read, so a truncated archive, a read error or a failing statement rolls the wipe back. An empty dump is refused — including a backup taken while pg_dump failed, which can be a valid archive of nothing. The new public schema goes to the database owner, not a role named after the database.

Laravel apps store the chosen engine in app metadata; backup, sync, and cipi app reset-db-password follow it. Use cipi app create --engine=pgsql (or the interactive prompt) to provision a Laravel app on PostgreSQL — .env and the connection URL match the engine. Root password reset: cipi reset db-password [--engine=].

cipi db upgrade (v5.2.3+)

Both engines are excluded from unattended-upgrades on purpose, so patch-level upgrades are a command you run:

bash
$ cipi db upgrade                  # every installed engine
$ cipi db upgrade mariadb [--yes]
$ cipi db upgrade pgsql   [--yes]

Expect brief downtime while the service restarts — the prompt says so before it starts. See manual stack upgrades.

cipi alias

Add multiple domains or subdomains to any app. cipi alias add|remove regenerates the vhost, which keeps serving the app's certificate (since v5.5.1 Cipi writes the HTTPS block itself), and cipi alias add tells you whether the new name is already covered by that certificate or which command adds it — usually cipi ssl install, which reissues one SAN certificate for all the domains.

bash
$ cipi alias add myapp www.myapp.com
$ cipi alias add myapp myapp.it
$ cipi alias add myapp '*.myapp.com'   # wildcard (v5.1.0+) — quote it
$ cipi alias list myapp
$ cipi alias remove myapp myapp.it

Since v5.1.0, wildcard aliases such as *.example.com are accepted — Nginx matches a wildcard server_name natively, and multi-tenant apps need it. Quote the pattern so the shell does not expand it. The alias routes the subdomains; the certificate comes from cipi ssl install myapp --dns=cloudflare, which includes the wildcard alias: HTTP-01 cannot validate a wildcard. With or without Cloudflare, proxy on or off, the whole procedure is in Wildcard domains & SSL.

For www ↔ apex canonical redirects, prefer cipi www instead of managing the counterpart host by hand.

cipi www

Available since v4.8.0. Manage www/apex aliases and canonical 301 redirects for an app. State lives in apps.json (www_redirect); Nginx emits a dedicated redirect server block (ACME path kept public) that survives vhost regeneration. SSL is re-applied via certbot install --redirect.

bash
$ cipi www add myapp              # add counterpart host (www ↔ apex)
$ cipi www force-to-root myapp    # 301 www.domain → domain
$ cipi www force-from-root myapp  # 301 domain → www.domain
$ cipi www clear myapp            # clear redirect state
$ cipi www status myapp           # inspect redirect state

force-to-root / force-from-root auto-add the missing alias when needed.

cipi redirect

Available since v5.3.1. Two kinds of redirect, both stored in apps.json (redirect and redirects[]) and rendered by the same vhost generator as everything else. They survive every vhost regeneration — alias, www, basic auth, PHP switch, cipi sync import — and certbot clones them into :443 like any other location.

bash
# The whole app moved: every name, www included, in one hop
$ cipi redirect set oldsite --to=https://new.com          # 301, path and query kept
$ cipi redirect set oldsite --to=https://new.com --302 --no-path
$ cipi redirect disable oldsite                           # serve the app again, keep the target
$ cipi redirect enable oldsite
$ cipi redirect unset oldsite                             # forget it

# Path redirects
$ cipi redirect add myapp /old-pricing /pricing           # exact, same app
$ cipi redirect add myapp /blog/ https://blog.example.com/ # prefix
$ cipi redirect add myapp /docs/ https://docs.example.com --308 --no-path
$ cipi redirect list myapp [--json]
$ cipi redirect remove myapp /old-pricing

App redirect

cipi redirect set <app> --to=<url> sends every name the app answers to — primary domain, aliases and the www host — to the target in a single hop. The path and query are kept by default (old.com/a?b → https://new.com/a?b); --no-path sends everything to the target as is. The status is --301 (default), --302, --307 or --308.

The ACME challenge stays public, so the old name's certificate keeps renewing and HTTPS visitors still get a valid redirect. A target served by the app itself is refused: it would loop. enable / disable toggle the saved target without losing it; unset removes it.

Path redirects

cipi redirect add <app> <from> <to>. <to> is a same-app /path or an http(s):// URL. Adding an existing <from> updates it.

<from> Matches Example
No trailing / Exact path. Only the query string is carried over, unless <to> has its own. /old?x=1 → /new?x=1
Trailing / Prefix. The rest of the raw request URI is appended; /blog without the slash goes to the target too. /blog/x?y → https://blog.example.com/x?y
Either, with --no-path Everything goes to <to> unchanged. /blog/x → https://blog.example.com/

Path redirects and proxy prefixes keep working while the app redirect is on: they are more specific than location /, so a site that moved can keep /api/ where it was.

What is refused

  • Paths and URLs outside a safe charset — no quotes, $, ;, braces or whitespace — so a rule cannot inject nginx directives. Write source paths decoded: nginx matches the decoded URI.
  • /, the ACME challenge, /favicon.ico, /robots.txt, /index.php, /cipi/webhook, and /app / /apps on a Reverb app.
  • A path another redirect or proxy already occupies — for example a redirect and a proxy both on /api/.
  • A redirect that would loop back into itself.
Safe apply. nginx -t must pass before the change. If nginx refuses the new vhost, apps.json and the vhost are restored and the command exits 1. cipi app show lists the app redirect and path redirects; changes fire the redirect_change notification (on by default). Since Cipi 5.4.1 the panel API (1.31+) and the GUI can manage redirects too, with the CLI's own validation; an app can also declare them in cipi.yml.

cipi proxy

Available since v5.3.1. Hand one URL prefix of an app to another HTTP service — a Node or Go API, a legacy app, a docs site — with location ^~ <prefix> and proxy_pass. Stored in apps.json (proxies[]), so it survives vhost regeneration like redirects do.

bash
$ cipi proxy add myapp /api/ http://127.0.0.1:3000 --strip-prefix
$ cipi proxy add myapp /events/ http://127.0.0.1:4000 --no-buffering --timeout=3600
$ cipi proxy add myapp /docs/ https://docs-origin.example.com --preserve-host
$ cipi proxy list myapp [--json]
$ cipi proxy remove myapp /api/
Option Effect
(none) The URI goes upstream unchanged: /api/users → <upstream>/api/users.
--strip-prefix /api/users → <upstream>/users, and X-Forwarded-Prefix is sent. Required when the upstream has a path (http://h/v1), because such an upstream always replaces the prefix.
--preserve-host Send the visitor's Host. By default Host is the upstream's.
--timeout=60 Upstream timeout in seconds.
--no-buffering For Server-Sent Events, long polling and streamed downloads.
--force Skip the loopback guard (below).

Every proxy gets WebSocket upgrade, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host, plus proxy_ssl_server_name for HTTPS upstreams. When basic auth is on, it covers the prefix too. Prefixes follow the same validation and reserved paths as path redirects, and keep working while an app redirect is on.

Loopback guard

A proxy to 127.0.0.1 or localhost on a port Cipi already uses is refused without --force: nginx itself (80/443), SSH, MariaDB, PostgreSQL, Valkey, Meilisearch, and any app's Octane or Reverb port. An upstream hostname that does not resolve is refused, since nginx resolves it at reload; one that does not answer within 5 seconds is only a warning.

As with redirects, nginx -t must pass or apps.json and the vhost are restored and the command exits 1. cipi app show lists the proxy prefixes, and changes fire proxy_change (on by default). See also cipi help proxy.

Since Cipi 5.4.1, the panel API (1.31+) and the GUI can add, list and remove proxies — always without --force, so the loopback guard cannot be bypassed from the web. Proxies declared in cipi.yml are stricter still: link-local and 0.0.0.0/8 upstreams, cloud metadata included, are refused, and resolved hostnames are checked as well as literal IPs.

cipi domains

Available since v4.5.5. A top-level command that lists every domain and alias across all apps in a single table — the global counterpart to the per-app cipi alias list <app>. Ideal for auditing the whole domain-to-app mapping or spotting a domain that is still missing a certificate.

bash
$ cipi domains   # list all domains and aliases across every app

Each row shows the following columns:

Column Description
DOMAIN The domain or alias name. Rows are sorted alphabetically by domain.
APP The app that owns the domain.
KIND primary or alias.
TYPE Laravel or Custom.
PHP The app's PHP version.
DOCROOT public for Laravel apps, /<docroot> for custom apps.
BRANCH The Git branch deployed for the app.
LAST DEPLOY Human-relative age (just now, 10m ago, 2h ago, 3d ago, 2w ago, 2mo ago, 2y ago) — derived from the mtime of the app's current symlink, which Deployer atomically re-points on every successful deploy. Shows - when the app was never deployed.
SSL Per-name certificate status (✓/✗), detected from /etc/letsencrypt/live/<domain>.
REPOSITORY The Git repository, or (SFTP only) for custom apps with no repo.
(suffix) When the owning app is suspended, each row ends with ⏸ suspended (yellow). The footer also reports how many apps are suspended.

A footer summarises the totals — number of domains, apps, certificates, and suspended apps — making it easy to audit the whole mapping at a glance.

cipi ssl

Certbot issues and renews Let's Encrypt certificates; since v5.5.1 Cipi writes the HTTPS server block of the vhost itself (certbot never edits an app vhost), so every vhost change — alias, www, basic auth, redirects, Octane, Reverb, suspend — keeps HTTPS, wildcard certificates included. Certificates renew through certbot.timer and a weekly cron, each the way it was issued, and nginx reloads after every renewal. cipi ssl status lists the certificates with their expiry and, per app, what it is served with and which of its names the certificate does not cover.

bash
$ cipi ssl install myapp   # provision / renew — includes all aliases (SAN)
$ cipi ssl force myapp     # re-apply HTTP→HTTPS redirect (no new issuance)
$ cipi ssl renew            # force renewal of all certificates
$ cipi ssl status           # certificates + per app: served with, names not covered

# DNS-01 via Cloudflare (v5.0+) — wildcard certs
$ cipi ssl dns set --token=YOUR_CF_TOKEN               # Zone → DNS → Edit
$ cipi ssl install myapp --dns=cloudflare              # includes a '*.myapp.com' alias
$ cipi ssl install myapp --dns=cloudflare --wildcard   # adds myapp.com + *.myapp.com anyway
$ cipi ssl install myapp --dns=cloudflare --no-wildcard
$ cipi ssl install myapp --http                        # back from DNS-01 to HTTP-01 (v5.5.1+)

# Several Cloudflare accounts (v5.5.0+)
$ cipi ssl dns set --name=client-a --token=CLIENT_A_TOKEN
$ cipi ssl install shop --dns=cloudflare --wildcard --account=client-a
$ cipi ssl dns list [--json]      # accounts and the certificates that renew with each
$ cipi ssl dns remove client-a    # refused while a certificate still renews with it

Since v4.8.0, cipi ssl force <app> re-applies the HTTP → HTTPS redirect for an app that already has a Let's Encrypt certificate — without issuing a new one. cipi ssl install also sets force_https in apps.json automatically.

Since v5.0, optional DNS-01 challenges via Cloudflare replace the default HTTP-01 flow when you need wildcard certificates or cannot expose port 80. Configure once with cipi ssl dns set, then pass --dns=cloudflare (and optionally --wildcard) to cipi ssl install. Since v5.5.1:

  • An app whose certificate was issued over DNS-01 is reissued over DNS-01 by a plain cipi ssl install <app> (and by the panel API), with the same account and wildcard. Before, it fell back to HTTP-01, which replaced the certificate's names and dropped the wildcard. --http goes back to HTTP-01 on purpose.
  • The certificate names are primary + aliases (plus <apex> + *.<apex> with --wildcard), without the names a wildcard already covers: Let's Encrypt refuses *.example.com and www.example.com in the same order. --wildcard is remembered; --no-wildcard drops it, and --wildcard without --dns is an error.
  • A wildcard certificate does not route anything: when *.<apex> is not one of the app's names, the install says cipi alias add <app> '*.<apex>'.

Behind the Cloudflare proxy, use SSL/TLS mode Full (strict) — Flexible loops on Cipi's HTTPS redirect. Error codes, Origin CA and the setup without Cloudflare: Wildcard domains & SSL.

Domains in different Cloudflare accounts (v5.5.0+)

A DNS-01 certificate needs an API token of the Cloudflare account that holds its zone. Up to 5.4 the server kept one token, in one file: a server hosting the domains of clients with their own Cloudflare accounts could serve only one of them, and setting a second token overwrote the first — the certificates issued with it then stopped renewing. Since v5.5.0 every account has a name:

  • cipi ssl dns set --name=NAME --token=TOKEN adds an account, any number of them. Without --name it is the default account, in the same file as before (/etc/cipi/cloudflare.ini), so a server with one token changes nothing. Named accounts live next to it, one root-only file each in /etc/cipi/cloudflare/. Running it again on an existing name replaces the token — that is how a token is rotated.
  • cipi ssl install <app> --dns=cloudflare --account=NAME issues with that account. Without --account an app keeps the account its certificate was last issued with, else default. An account that is not configured is refused before certbot runs, with the list of the ones that are.
  • Renewals need nothing. certbot records the credentials file in each certificate's renewal config, so every certificate renews with the token of its own account.
  • cipi ssl dns list shows every account with the certificates that renew with it (--json too); tokens are never printed. cipi ssl status names the account next to each DNS-01 certificate.
  • cipi ssl dns remove <NAME> refuses while a certificate still renews with that account: without its token the certificate would fail to renew, silently, until it expired.

Account names are lowercase letters, digits, - and _. The credentials file is now written under a umask that keeps the token unreadable by other users even while it is being written — before 5.5.0 it was world-readable for an instant inside /etc/cipi. When issuance fails, the error says which account was used and that it must hold the zone.

Since v5.3.0, cipi ssl knows about Cloudflare Zero Trust. HTTP-01 refuses while cipi zt lock http is on — Let's Encrypt does not validate from Cloudflare's IPs — and on an app that uses a Cloudflare Origin CA certificate, which it would overwrite; use --dns=cloudflare instead. Apps routed through the tunnel get certbot's --no-redirect, and cipi ssl force refuses on them, because cloudflared talks plain HTTP to :80 and an origin redirect would break it.

After cipi alias add, run the command it prints — cipi ssl install for a new name, --dns=cloudflare for a wildcard — unless it says the name is already covered (a *.example.com certificate covers every new tenant subdomain). cipi ssl force turns the HTTP → HTTPS redirect back on without reissuing.

cipi nginx default-server

Available since v5.1.0. Claims whichever of :80 / :443 has no default server and closes unmatched requests with an empty reply (444).

bash
$ cipi nginx default-server status
$ cipi nginx default-server on
$ cipi nginx default-server off

Cipi always had a default server on :80, but never on :443. An HTTPS request carrying an unknown Host therefore fell through to whichever vhost Nginx had loaded first — for a wildcard multi-tenant app, straight into a tenant resolver that cannot parse it.

It is enabled on fresh installs, and migration 5.1.0 claims it on existing servers where nothing else already does. If another vhost legitimately owns the default server, the command says so and changes nothing.

cipi nginx upgrade (v5.2.3+)

Nginx is on the unattended-upgrades blacklist, so patches are applied when you ask for them: cipi nginx upgrade [--yes] runs apt --only-upgrade over the installed nginx* packages, keeps your nginx.conf, checks the config with nginx -t and reloads. Details in manual stack upgrades.

cipi backup

Back up application files and databases to local disk, to Amazon S3, or to any S3-compatible provider (Cloudflare R2, Hetzner Object Storage, DigitalOcean Spaces, Backblaze B2, Scaleway, MinIO, …).

Since v5.1.0 backups are driven by profiles instead of one hardcoded nightly job. A profile answers four questions — what it takes, how often it runs, where it goes and how long it is kept — and profiles are independent, so a 30-minute database-only copy kept on disk sits happily next to an encrypted nightly full copy on S3.

Upgrading from 5.0.x? Migration 5.1.0 converts the old nightly job into a profile named default, carrying over its existing --weeks retention, and takes over the schedule with a managed crontab block. Nothing outside that block is touched, and cipi backup prune <app> --weeks=N still prunes the pre-5.1 layout.

1 — Destinations

cipi backup configure stores the default bucket — the one a profile names as s3. Since v5.1.0 you may leave the bucket empty for local-only backups, which land under /var/backups/cipi.

bash
$ cipi backup configure
# → AWS Access Key ID
# → AWS Secret Access Key
# → Bucket name        (leave empty for local-only backups)
# → Region
# → Endpoint URL       (leave empty for AWS; required for other providers)

$ cipi backup status                 # destinations, profiles, last run, overdue

S3-compatible endpoints

Provider Endpoint URL
AWS S3 leave empty
Cloudflare R2 https://<account-id>.r2.cloudflarestorage.com
Hetzner https://<datacenter>.your-objectstorage.com
DigitalOcean Spaces https://<region>.digitaloceanspaces.com
Backblaze B2 https://s3.<region>.backblazeb2.com
Scaleway https://s3.<region>.scw.cloud
MinIO https://your-minio-host

Several buckets — named destinations

Since v5.6.0 a server can ship to more than one bucket: another provider, another region, a customer's own account. Each extra bucket is a named destination with its own keys, region and endpoint; a profile then lists any of them next to local and s3. On a server that already had a bucket nothing changes: that bucket stays the default, s3 in every profile.

bash
$ cipi backup destination add offsite \
      --bucket=acme-backups --key=AKIA... --secret=... \
      --region=eu-central-1 --endpoint=https://eu-central-1.your-objectstorage.com
# the bucket is listed before anything is saved; without flags the values are asked

$ cipi backup destination list              # local, s3 (default) and every named one, with the profiles using it
$ cipi backup destination show offsite
$ cipi backup destination test              # reach every bucket with its own credentials
$ cipi backup destination edit offsite --endpoint=https://...
$ cipi backup destination remove offsite    # refused while a profile still uses it; archives stay

$ cipi backup profile edit nightly --dest=local,s3,offsite

Every run uploads the same layout to each bucket; retention, cipi backup list, verify and fetch look at all of them. In cipi.yml: destinations: [s3, offsite] — a name not configured on the server blocks the plan.

2 — Backup profiles

Create a profile once; it then runs on its own schedule. Two typical ones:

bash
# Every 30 minutes — databases only, kept on the server, last 48 runs
$ cipi backup profile add hourly-db --scope=db \
      --databases='shop,tenant_*' --exclude-tables='*.jobs,*.telescope_*' \
      --every=30m --keep=48 --dest=local

# Every night at 02:00 — files + databases, encrypted to S3, kept 14 days
$ cipi backup profile add nightly --scope=all \
      --cron='0 2 * * *' --keep-days=14 --dest=s3 --encrypt
bash
$ cipi backup profile list                 # all profiles
$ cipi backup profile show nightly         # one profile in detail
$ cipi backup profile edit nightly --keep-days=30
$ cipi backup profile disable hourly-db    # pause it without deleting it
$ cipi backup profile enable hourly-db
$ cipi backup profile remove hourly-db     # deletes the profile, keeps its archives

Flags for profile add / profile edit

--scope=all|files|dbWhat the profile takes: application files, databases, or both.
--apps='shop,blog'Which apps its file archives cover. Glob patterns allowed.
--databases='main,tenant_*'Which databases it covers. They are discovered from the engine itself, so tenant databases an app creates at runtime are matched too.
--exclude-databases='<glob,…>'Databases to leave out of an otherwise broad selection.
--exclude-tables='*.jobs,*.telescope_*'Tables to skip — expanded against the live table list on MariaDB, passed straight to pg_dump on PostgreSQL.
--every=30m|6h|1d or --cron='0 2 * * *'How often it runs.
--dest=local,s3,<name>Where the run goes: local, s3 (the default bucket) and, since v5.6.0, any named destination (cipi backup destination add).
--keep=N / --keep-days=N / --keep-weeks=NRetention — at least one is mandatory. A profile that would grow without bound is refused.
--encrypt / --no-encryptClient-side AES-256 encryption before anything leaves the server.
Multi-tenant apps are covered now. Before 5.1.0 a run dumped exactly one database per app — the one named after the app — so tenant_1, tenant_2, … were never saved at all. Databases now come from the engine (minus system schemas) and are selected with glob patterns.

3 — Encryption

With --encrypt the archive is encrypted with AES-256 on the server, before it is uploaded, so the bucket operator never holds readable data. The manifest stays readable so a run can still be identified.

bash
$ cipi backup key show      # print the key — store it off-server
$ cipi backup key rotate    # new key for future runs
Without the key an encrypted backup cannot be restored — not by you, not by Cipi. Save it in a password manager the moment you enable --encrypt, and remember that runs taken before a key rotate still need the old key.

4 — Running, checking and restoring

bash
$ cipi backup run                          # run every enabled profile now
$ cipi backup run --profile=nightly        # run one profile now
$ cipi backup run --dry-run                # show what it would take, take nothing
$ cipi backup list [--profile=nightly]     # runs held on each destination
$ cipi backup verify                       # does the newest run of each profile open?
$ cipi backup verify --deep                # same, downloading from S3 to check
$ cipi backup prune [--profile=nightly]    # apply retention now
$ cipi backup fetch nightly 2026-09-02_020000   # download and decrypt one run

Since v5.1.0 these commands tell you the truth rather than reassuring you:

  • Every archive is integrity-checked before it ships. A dump cut short by a full disk is still a non-empty file, so the old size check passed it; now a corrupt archive is discarded and reported instead of quietly replacing a good backup.
  • cipi backup fetch no longer reports success on a prefix that holds no objects, so a mistyped timestamp is an error rather than an empty directory.
  • cipi backup verify says plainly that there is nothing to verify when no run has happened yet, instead of “passed”.
  • tar warnings — “file changed as we read it”, constant on a live app writing its logs — no longer fail a perfectly good archive. Only exit code 2 and above counts.
  • A disabled profile actually stops running, and Encrypted is displayed correctly.

To restore, fetch the run and feed the pieces back:

bash
$ cipi backup fetch nightly 2026-09-02_020000 --dest=/root/restore
$ cipi db restore myapp /root/restore/databases/mariadb/myapp.sql.gz
$ tar -tzf /root/restore/apps/myapp/files.tar.gz

5 — Storage layout

Application files and databases are stored separately, with a manifest per run — so a database-only profile costs nothing to take, and a database can be restored without unpacking an app.

text
<root>/<profile>/<YYYY-MM-DD_HHMMSS>/
├── manifest.json
├── apps/
│   └── myapp/
│       ├── files.tar.gz
│       └── meta.json
└── databases/
    └── mariadb/
        └── myapp.sql.gz

# local  → /var/backups/cipi/<profile>/<timestamp>/
# s3     → s3://<bucket>/cipi/<profile>/<timestamp>/
# <name> → the same prefix in that destination's bucket

Backup staging defaults to /var/tmp (disk) rather than /tmp (often a small RAM-backed tmpfs), so large apps do not fail mid-backup when tmpfs fills up. Override with tmpdir in backup.json (set via cipi backup configure) or the CIPI_BACKUP_TMPDIR environment variable.

6 — Schedule and the staleness watchdog

You no longer write cron lines by hand. cipi backup configure and every profile change rewrite a marked block in root's crontab; anything outside that block is left alone, and pre-5.1 hand-written lines are absorbed on migration.

An hourly watchdog raises the backup_stale notification when a profile has not succeeded within twice its own interval — because a backup that quietly stopped running is worse than no backup at all: it still looks configured. A freshly created profile is not reported as overdue before its first scheduled run could have happened.

Legacy pruning

cipi backup prune <app> --weeks=N keeps working against the pre-5.1 layout (s3://<bucket>/cipi/<app>/<timestamp>/ plus pre-deploy dumps in /var/log/cipi/backups), so existing archives and any hand-written cron line still prune. New profiles use --keep, --keep-days or --keep-weeks instead.

Fixed in v5.4.0: old backups were never deleted. Migration 5.1.0 removed the cipi backup prune --weeks=N cron line, which was the only thing pruning the pre-5.1 layout — so archives written before the upgrade, archives of a removed profile and the dumps in /var/log/cipi/backups/ (where cipi deploy --snapshot still writes) stayed forever. Every backup run and cipi backup prune now prune those orphans too, local and S3, keeping them as long as the longest age-based retention of any profile. With count-only retention (keep: N) there is no age to go by, and orphans are left alone; --dry-run lists them. S3 listing and delete errors are no longer swallowed: they are logged, send a backup_fail alert without failing the backup, and make cipi backup prune exit 1. Root's cron PATH now includes /usr/local/bin, where the AWS CLI lives.
Meilisearch is not in cipi backup, and that is deliberate. A Scout index is derived data; scout:import rebuilds it from the database that is backed up. Adding the store to S3 would mean paying to keep a rebuildable artifact whose format is pinned to an engine version. See cipi search.
An app can declare its own backup profiles in its repository — see cipi.yml. Profiles owned by an app must be named <app> or <app>-*; server-wide profiles stay under your control only.

User crontab

Cipi automatically adds a crontab entry for the Laravel scheduler when an app is created:

bash
# installed automatically by cipi app create
* * * * * /usr/bin/php8.5 /home/myapp/current/artisan schedule:run >> /dev/null 2>&1

This entry runs as the myapp Linux user every minute, using the PHP version selected for the app. It is updated automatically when you change PHP version via cipi app edit myapp --php=X.

Viewing the current crontab

bash
# as root — view the app user's crontab
$ crontab -u myapp -l

# or after switching to the app user
$ su - myapp
myapp@server:~$ crontab -l

Adding custom cron jobs

You can add extra cron jobs to the app user's crontab. Switch to the app user first to ensure jobs run with the correct user context and file permissions:

bash
$ su - myapp
myapp@server:~$ crontab -e

Example entries you might add:

bash
# existing Laravel scheduler (do not remove)
* * * * * /usr/bin/php8.5 /home/myapp/current/artisan schedule:run >> /dev/null 2>&1

# nightly database backup at 2 AM
0 2 * * * /usr/local/bin/cipi db backup myapp >> /home/myapp/logs/backup.log 2>&1

# custom script every 15 minutes
*/15 * * * * /home/myapp/current/scripts/sync.sh >> /home/myapp/logs/sync.log 2>&1
Do not remove the Laravel scheduler entry. Cipi does not re-add it automatically if deleted — you would need to run cipi app edit myapp --php=<current-version> to restore it. Always keep the schedule:run line as the first entry so it is easy to identify.
Cron jobs run as the app user. They respect the same filesystem restrictions as the app itself. If a cron job needs to write files, make sure the target path is inside /home/myapp/. Jobs that require root access should be added to the root crontab instead, with crontab -e as root.

Checking if cron is working

bash
# check system cron log
$ grep CRON /var/log/syslog | grep myapp | tail -20

# check Laravel scheduler execution
$ cipi app artisan myapp schedule:list

cipi schedule

Since v5.0, manage the Laravel scheduler crontab entry that runs schedule:run (the crontab itself already existed; it is now toggleable and tracked in apps.json).

bash
$ cipi schedule on myapp
$ cipi schedule off myapp
$ cipi schedule status myapp

cipi worker & Laravel Horizon

Every app gets a default Supervisor worker for the default queue. You can add additional queues with custom process counts and timeouts.

bash
$ cipi worker add myapp --queue=emails --processes=3
$ cipi worker add myapp --queue=exports --processes=1 --timeout=7200
$ cipi worker list myapp
$ cipi worker edit myapp --queue=default --processes=3
$ cipi worker remove myapp emails
$ cipi worker restart myapp   # restart all workers for the app
$ cipi worker stop myapp      # stop all workers for the app (used during deploys)

# Laravel Horizon (v5.0+) — mutually exclusive with queue:work workers
$ cipi worker horizon enable myapp
$ cipi worker horizon status myapp
$ cipi worker horizon disable myapp

With Horizon enabled, deploy runs horizon:terminate and restarts workers via cipi-worker. You cannot run classic queue:work workers and Horizon on the same app at once.

On 5.0.x, cipi worker horizon enable could leave Horizon half enabled: the command printed nothing after “Enabling Horizon…” and status still said disabled. v5.1.0 fixes both causes, always writes the state, reports what Supervisor actually did, and makes status surface any drift between the two. If you hit this, run cipi self-update and enable it again.

Queue workers can also be declared in the app's repository — see cipi.yml.

Flag Description
--queue=<name> Queue name to consume (e.g. default, emails, exports)
--processes=<n> Number of parallel worker processes
--timeout=<seconds> Job timeout in seconds. Default is 60.

Workers are stopped before the symlink swap and restarted after every deploy, preventing Supervisor from picking up stale artisan paths. Supervisor is configured with autorestart=unexpected so workers only restart on unexpected exits, not on graceful stops.

App-user helper: cipi-worker

Each app user can restart, stop, or check workers without root — via a restricted sudo helper installed at /usr/local/bin/cipi-worker:

bash
# run as the app user (SSH or sudo su - myapp)
$ sudo cipi-worker status myapp
$ sudo cipi-worker stop myapp      # used by Deployer before symlink swap
$ sudo cipi-worker restart myapp

As root, use cipi worker list|restart|stop <app> instead. There is no cipi worker status admin command — use cipi worker list or the app-user helper above.

cipi health

Since v5.0, configure HTTP healthchecks per app. Cipi then watches the app in two different ways, because “is the site up?” and “did the push I just made break production?” are not the same question.

Check When it runs When it alerts
Periodic probe Every 5 minutes After 3 consecutive failures → health_fail
Post-deploy check Right after every release goes live Immediately on the first failure → deploy_health_fail
bash
$ cipi health set myapp --url=https://myapp.com/up --expect=200
$ cipi health check myapp
$ cipi health list
$ cipi health unset myapp

# Structured output (v5.0.6+) — panel API / scripts
$ cipi health list --json
$ cipi health check myapp --json

Post-deploy verification (v5.1.0+)

Once an app has a healthcheck URL, the release that just went live is verified right after every deploy — from cipi deploy and from the Git webhook alike. The probe waits a short grace period (8 seconds for Octane apps, 3 otherwise, or whatever --grace=N says) and retries five times, so an app that needs a moment to come up is not reported as broken.

The verdict never changes the deploy's exit code — the release is live either way — but the alert says so and hands you the rollback command. The success email is sent after verification, so it can never announce a successful deploy while the site is returning 500.

bash
$ cipi health postdeploy myapp             # run the post-deploy verification now
$ cipi health set myapp --grace=15         # give the release longer to warm up (max 120)
$ cipi health set myapp --no-postdeploy    # turn the post-deploy check off for this app

Auto-rollback of an unhealthy release (opt-in)

Cipi can undo a release that fails its post-deploy healthcheck: the current symlink moves back to the previous release, the app is probed again, and one email describes the whole sequence — what was published, what it answered, what it was rolled back to and whether that fixed it.

bash
$ cipi health set myapp --rollback-on-unhealthy   # permanent, per app
$ cipi deploy myapp --rollback-on-unhealthy       # just this deploy

Four outcomes are reported distinctly: recovered; rolled back but still unhealthy (so the cause is probably not the code); the rollback itself failed (the bad release is still live); and there is no earlier release to return to.

Auto-rollback is off by default and deliberately so: database migrations are not undone. A release that migrated the schema and then failed can be worse off after the code is rolled back. Enable it only for apps whose deploys do not migrate, or where you know the migrations are backwards-compatible.

REST: GET /api/health, GET|PUT|DELETE /api/apps/{name}/health, POST /api/apps/{name}/health/check (API 1.15.0+ / Cipi 5.0.6+). An app can also declare its healthcheck in its repository — see cipi.yml. To watch the server rather than the app — disk, services, workers, 5xx spikes, load — see cipi monitor (v5.3.0+).

cipi monitor

Available since v5.3.0. cipi health watches app URLs; cipi monitor watches the server itself — from cron, every 5 minutes (/etc/cron.d/cipi-monitor runs /usr/local/bin/cipi-monitor). No metrics storage, no graphs, no agent: a few state files under /var/log/cipi/monitor/ and a message when something actually breaks.

bash
$ cipi monitor                               # run every check now — exits 1 on warn/crit
$ cipi monitor --json                        # same, machine-readable
$ cipi monitor list                          # checks, thresholds, current state, last alert
$ cipi monitor disable load                  # turn one check off
$ cipi monitor enable load
$ cipi monitor set disk --warn=85 --crit=95
$ cipi monitor set app_disk --warn=90 --minutes=30   # v5.5.0+ — per-app soft disk limits
$ cipi monitor set ssl --days=21
$ cipi monitor set http_5xx --count=50 --ratio=10
$ cipi monitor set load --factor=6 --runs=3
$ cipi monitor set reminder --minutes=120    # re-alert interval while failing (min 15)
$ cipi monitor test                          # sample alert through email and every channel

Eight checks, all on by default

Check What it looks at Alerts when (default) Trigger
disk Every local filesystem warn at ≥80%, crit at ≥90% monitor_disk
app_disk (v5.5.0+) Files + database of every app that has a soft disk limit — apps without one (the default) are not measured warn at ≥90% of the limit, crit once over it, one alert state per app monitor_app_disk
ssl Let's Encrypt certificates in /etc/letsencrypt/live warn when expiry is ≤14 days away, crit once expired monitor_ssl
services Every installed Cipi service via systemd — nginx, MariaDB, PHP-FPM, Valkey, Supervisor, fail2ban, and the optional ones when present Any unit not active monitor_services
workers Every configured *-worker-* / *-horizon Supervisor program A process not RUNNING monitor_workers
http_5xx The bytes appended to each app's Nginx access log since the previous run ≥20 5xx and ≥5% of requests monitor_http_5xx
fs /etc/cipi and /var/log/cipi Either one is no longer writable (read-only remount) monitor_fs
load 1-minute load average Above 4× the core count for 2 consecutive runs monitor_load

Edge-triggered, so the cron never spams

  • ok → warn/crit fires once, and a warn ↔ crit escalation fires once.
  • fail → ok sends one recovery message (monitor_ok) that says how long the check had been failing.
  • A failure that persists re-alerts every reminder_minutes — 240 by default, cipi monitor set reminder --minutes=N to change it.

Details that keep it honest

  • http_5xx keeps a byte offset per log, so it only reads what was written since the last run. The offsets survive logrotate, and the first run only records a baseline — it never alerts on last week's errors.
  • load needs runs consecutive runs over the limit: a single 5-minute spike is noise, a sustained one is a problem.
  • app_disk walks app homes with du, so it measures only apps that have a limit and reuses a measurement for minutes (30 by default): the 5-minute cron does not walk every home each time. A manual cipi monitor measures afresh. Each app keeps its own alert state, so a second app going over is not hidden behind the first, and a recovery follows when an app is back under its limit.
  • A check that cannot run reports crit instead of crashing the runner.
  • Every check maps to its own trigger, so any of them can be muted like everything else: cipi notifications disable monitor_load.
Alerts go out through the normal notification path: email once cipi smtp configure is done, and every chat channel you add. Migration 5.3.0 installs the cron and the helper and writes monitor.json with all seven checks on; setup.sh does the same on fresh servers. The panel API sudoers allow cipi monitor list; enable, disable and set stay on the CLI.

cipi disk

Available since v5.5.0. cipi monitor tells you the disk is filling up; cipi disk tells you who is filling it. It prints every local filesystem, then every app, largest first, in GB and as a share of the disk. The same reading from your laptop is cipi-cli disk (cipi-cli 1.6.0+, API 1.33+, ability disk-view; the server needs 5.5.2+ so the API may run cipi disk).

bash
$ cipi disk                        # the server, then every app
$ cipi disk db                     # every database, engine by engine, in MB
$ cipi disk --json                 # same figures for scripts (also: cipi disk db --json)
$ cipi app limits shop --disk=10   # soft limit: alert at 90% and when over, block nothing
output
  Server disk
  MOUNT                          SIZE         USED         FREE     USE
  /                         100.00 GB     40.00 GB     60.00 GB     40%

  Apps (3) — % of the 100.00 GB on /
  APP                           FILES     DATABASE        TOTAL    DISK  LIMIT
  shop                        2.00 GB      1.00 GB      3.00 GB    3.0%  10 GB (30%)
  blog                        0.50 GB     120.4 MB      0.62 GB    0.6%  —
  front                       0.10 GB      0.00 GB      0.10 GB    0.1%  —
  All apps                                              3.72 GB    3.7%
  Everything else                                      36.28 GB   36.3%
Column What it measures
FILES The app's home: releases, shared storage, logs
DATABASE The database named after the app and, when shared/.env points DB_DATABASE at another one, that one too. The size is what the server reports (information_schema for MariaDB, pg_database_size for PostgreSQL); for MariaDB it is compared with the database's directory in the data dir and the larger of the two is used. If the server does not answer, the directory alone is measured. Custom and Node apps have no database and show 0.
TOTAL / DISK Files plus database, and that total as a percentage of the filesystem that holds /home
LIMIT The app's soft disk limit and how much of it is used — over once passed, — when the app has none
Everything else The rest of the used space: system, packages, logs, local backups, databases that belong to no app

Sizes below 0.01 GB are printed in MB (4.2 MB), so a small database does not read as 0.00 GB. --json carries the exact files_kb, database_kb and total_kb next to the rounded GB, plus limit_gb, limit_percent and over_limit for every app. Sizes are measured with du when the command runs — nothing is stored — so a server with large apps takes a few seconds. Read-only, root CLI; cipi help server and tab-completion know it.

cipi disk db — every database, in MB

Next to the per-app figure, a listing of the databases themselves, for every engine installed on the server. It shows what the app table cannot: databases that belong to no app, and the engines that are not SQL.

output
  MariaDB
  DATABASE                                               SIZE
  blog                                                 2.0 MB
  shop                                               200.0 MB
  On disk, whole engine                              500.0 MB

  Valkey
  DATABASE                                     KEYS      SIZE
  db0                                          1204         —
  Memory in use                                       12.3 MB
  On disk, whole engine                                3.0 MB

  Meilisearch
  INDEX                                   DOCUMENTS      SIZE
  shop-products                               12000    8.4 MB
  On disk, whole engine                               45.0 MB
  • MariaDB — one row per database, empty ones included. The size is the larger of what the server reports and the database's directory in the data dir, since the files include space the server has not handed back. These are the figures the DATABASE column of cipi disk uses.
  • PostgreSQL — one row per database (pg_database_size).
  • Valkey — one row per logical database (db0, db1, …) with its number of keys. Valkey does not know the size of a single database, so the size is the instance's: memory in use and files on disk. The password reaches valkey-cli through the environment, not the command line.
  • Meilisearch — one row per index with its number of documents, and the size of its documents where the installed version reports it. The total is the data.ms directory.

Every engine closes with its size on disk as a whole (data directory, logs included). An engine that is installed but does not answer gets a note and still shows that total; an engine that is not installed is left out.

A soft disk limit per app

No app has a limit until you give it one — the default is unlimited, on new and existing servers alike. A limit is a number of GB for files and database together (the TOTAL column), set with cipi app limits <app> --disk=<GB>. It is soft: nothing is blocked when it is passed and the app keeps working. The limit is a threshold for the app_disk monitor check, which warns at 90% of it, goes critical once the app is over, and sends a recovery when it is back under — through email and every chat channel, trigger monitor_app_disk.

A hard limit (filesystem quotas) is deliberately not part of this: it cannot cover the database, needs quotas enabled on the filesystem, and takes a site down when it is reached. The whole-server alert did not change: the disk check has warned at 80% and gone critical at 90% of any filesystem since 5.3.0, on by default.

cipi firewall

Cipi installs UFW with ports 22, 80, and 443 open by default. Use the firewall commands to manage additional rules without touching UFW directly.

bash
$ cipi firewall allow 3306                  # open a port
$ cipi firewall allow 3306 --from=10.0.0.5  # allow from specific IP
$ cipi firewall allow 3306 --from=10.0.0.0/24 # allow from subnet
$ cipi firewall deny 8080                   # block a port
$ cipi firewall list                        # show all rules

# Failed SSH logins before fail2ban bans an address (v5.5.0+)
$ cipi firewall attempts                    # threshold, counting window, ban time, default or not
$ cipi firewall attempts 5                  # ban after 5 failed logins (1–100)
$ cipi firewall attempts default            # back to 3

Brute-force defaults

UFW denies all inbound traffic except SSH, HTTP and HTTPS, and fail2ban bans the addresses that keep failing SSH logins. These are the defaults since 4.3.0:

Setting Default
Failed SSH logins before a ban 3 — cipi firewall attempts changes it
Counted over 1 hour
Ban 24 hours, longer for each repeat, up to 7 days
Repeat offenders (recidive) 3 bans in 24 hours → banned for 7 days

cipi firewall attempts (v5.5.0+)

Up to 5.4 the only way to change the threshold was to edit /etc/fail2ban/jail.local by hand — a file Cipi owns. Now:

  • The value is written to /etc/fail2ban/jail.d/cipi-attempts.local ([sshd] maxretry). fail2ban reads jail.d/*.local after jail.local, so the value wins and no Cipi update resets it. jail.local itself is not edited.
  • The configuration is checked with fail2ban-client -t before the reload. If fail2ban refuses it, the previous state is put back and nothing changes. With fail2ban stopped the value is saved and applies when it starts.
  • Only the sshd jail changes. Window, ban time and the recidive jail stay as they are. 1 is accepted, with a warning: one mistyped password then bans its owner.
  • CLI only (root), logged in cipi.log. cipi compliance report adds the drop-in to its fail2ban evidence.

Servers keep the default of 3 until the command is used: the 5.5.0 migration does not touch fail2ban.

Since v5.3.0, once Cloudflare Tunnel carries the traffic, cipi zt lock http can close 80/443 (or allow only Cloudflare's ranges) and cipi zt lock ssh can close 22. cipi zt disable puts the three default rules back.

cipi ban

Inspect and manage Fail2ban bans directly from the CLI. Cipi configures Fail2ban with progressive banning: a 24-hour base ban that doubles on each repeat offence up to a 7-day cap, with max retries reduced to 3 (since v5.5.0, cipi firewall attempts changes that number). A dedicated recidive jail bans repeat offenders for 7 days after 3 bans within 24 hours.

bash
$ cipi ban list                        # list all banned IPs, grouped by jail
$ cipi ban unban 203.0.113.42           # unban a specific IP from all jails

cipi ban list

Lists every IP currently banned by Fail2ban, grouped by jail (e.g. sshd, recidive). Useful for a quick security check or before running an unban.

cipi ban unban <IP>

Removes the given IP from all Fail2ban jails at once. Handy when a legitimate user or CI runner gets locked out by mistake.

Since v5.2.0 both commands also cover CrowdSec whenever its engine is running. cipi ban list reads .decisions[] out of cscli decisions list -o json, which returns alerts: reading .value at the top level printed a decision count and then no addresses at all. And cipi ban unban now asks before it claims success, because cscli decisions delete exits 0 whether it removed something or nothing.

Existing installations are upgraded automatically by the 4.3.0 migration script when you run cipi self-update. No manual configuration is needed.

cipi crowdsec

Available since v5.2.0, and off by default: neither setup.sh nor cipi self-update installs it. CrowdSec reads your Nginx logs, decides which IPs are attacking, and a firewall bouncer drops them. It is not a WAF — no request is inspected or rewritten in flight, and fail2ban stays exactly where it is.

bash
$ cipi crowdsec enable                # engine + firewall bouncer
$ cipi crowdsec status                # engine / bouncer / rescue / decisions
$ cipi crowdsec allow 203.0.113.42     # never ban this address
$ cipi crowdsec unallow 203.0.113.42   # drop an extra allow
$ cipi crowdsec rescue token           # print the break-glass curl (on-box)
$ cipi crowdsec rescue rotate          # new one-shot token, mailed to you
$ cipi crowdsec disable                # flush the rules, then remove

What enable actually installs

The engine and crowdsec-firewall-bouncer-nftables (or the -iptables variant), registered with cscli bouncers add. That pairing is the whole point: decisions without a bouncer ban nothing at all. On Ubuntu 24.04 iptables is nft underneath, so the bouncer lands on the same plane as fail2ban's iptables-multiport.

Acquisition reads /home/*/logs/nginx-{access,error}.log, which is where every Cipi vhost actually writes. /var/log/nginx alone holds only the catch-all server block, so the nginx scenarios would never see a single line of application traffic.

When enable refuses

  • Below 512MB free RAM. The engine plus the bouncer needs headroom you do not have.
  • Nginx looks like it sits behind a reverse proxy without set_real_ip_from and real_ip_header. Every log line would carry your CDN's address, so CrowdSec would ban Cloudflare and leave the attacker alone. --force overrides it if you know better. Since v5.3.0 the error points at cipi zt enable, which writes exactly that Cloudflare real_ip config.

Before anything can start banning, enable allowlists the SSH session you are typing from. And cipi crowdsec disable flushes the CrowdSec chains and tables first, then purges: removing the packages while DROP rules are still loaded would leave bans that outlive cscli — and nothing left to lift them with.

Rescue TLS listener

Automatic banning and remote administration are one bad decision away from each other, so enable also starts a TLS server on a high port. One GET carrying a one-shot token allowlists the TCP peer — never X-Forwarded-For — unbans that address in CrowdSec and fail2ban, mails you a new token, and then the token dies. It is not a login and not an SSH key: it buys back the ability to connect, nothing more.

UFW opens the port, and an ACCEPT is punched at the head of the CrowdSec chain — and punched again on every bouncer restart — so a DROP-all ban cannot close the door behind you.

The TLS handshake runs in the connection's own thread with a deadline, never inside accept(): wrapping the listening socket would let a single peer that connects and then says nothing hold the break-glass path shut for as long as it likes. Concurrent connections are capped below the unit's TasksMax.

cipi crowdsec status shows the port and the certificate fingerprint — never the token. The throttle applies to wrong tokens only: a correct token is always honoured, whatever the peer typed before it.

Allowlists

Shipped so that your own infrastructure never bans itself:

  • Localhost, RFC1918 ranges, and Let's Encrypt HTTP-01 validation.
  • GitHub webhook CIDRs, pulled from the .hooks key of api.github.com/meta. The fetch is fail-open: if it dies, the previous file stays in place.
  • GitLab.com egress CIDRs, hardcoded and dated 2026-09-07. They are documented as rotting, because they will.
  • Anything you add yourself with cipi crowdsec allow <ip|cidr>.

cipi ban list and cipi ban unban now include CrowdSec whenever the engine is up, so there is one place to look and one place to lift a ban.

CrowdSec runs 24/7 once enabled — unlike cipi scan, which is the nightly job. cipi status lists the engine, its bouncer and the rescue listener only when those units exist: a rescue listener that has stopped is the one thing you want to notice before you need it.

Hardening of the rescue unit

The listener runs as root — redeeming a token needs it — but with NoNewPrivileges, ProtectHome=read-only, PrivateTmp and a MemoryHigh cap. ProtectSystem and SystemCallFilter are deliberately left off: hardening that can break the break-glass path is worse than the surface it removes.

Migration 5.2.0 installs cipi-scan-manifest and the two rescue helpers, creates the manifest store, grants every existing app its manifest sudo entry, and adds SSH_CLIENT / SSH_CONNECTION to env_keep in /etc/sudoers.d/cipi-sudo. That last one matters: setup.sh sets PermitRootLogin no, so the normal path is sudo cipi, and sudo's env_reset was dropping the only variables that carry the operator's IP. The candidate file is validated with visudo -c before it replaces the live one. Nothing else is migrated: CrowdSec and the scan stay off.

cipi zt — Cloudflare Zero Trust

Available since v5.3.0, and off until you ask — the same contract as CrowdSec: setup.sh and cipi self-update never install cloudflared and never call cipi zt enable. Fail2ban and UFW stay; this feature teaches them about Cloudflare rather than replacing them.

Public sites stay public — visitors of a shop go through Cloudflare's CDN/WAF over the same tunnel, with no login. Staging, the GUI and SSH are what you put behind Cloudflare Access. Every step that could cut you off is a separate, explicit command.

bash
# 1 — API token, saved root-only
$ cipi zt token set --token=CF_API_TOKEN --account=ACCOUNT_ID
$ cipi zt token show                         # whether a token is saved — never the secret

# 2 — tunnel + nginx real_ip (22/80/443 stay open)
$ cipi zt enable
$ cipi zt status                             # tunnel, hostnames, Access, locks, apps not on the tunnel

# 3 — route traffic
$ cipi zt hostname add myapp                 # public, through Cloudflare's CDN/WAF
$ cipi zt hostname add --gui                 # the control panel hostname
$ cipi zt access enable staging              # Cloudflare Access in front of an app
$ cipi zt access enable --gui
$ cipi zt ssh enable --hostname=ssh.example.com

# 4 — close the origin, when you are ready
$ cipi zt lock http --yes                    # 80/443: closed, or Cloudflare IPs only
$ cipi zt lock ssh --yes                     # port 22 — refuses unless the tunnel carries SSH

# rescue and teardown
$ cipi zt ssh unlock                         # reopen port 22
$ cipi zt unlock http                        # 80/443 world-open again
$ cipi zt refresh                            # reload Cloudflare IP ranges (also a daily cron)
$ cipi zt disable                            # restore UFW 22/80/443, remove cloudflared

The API token

cipi zt token set stores the token root-only at /etc/cipi/zt.token. If --account is omitted and the token sees exactly one account, Cipi uses it. The token needs Account · Cloudflare Tunnel: Edit, Account · Access: Apps and Policies: Edit and Zone · DNS: Edit; Origin CA also needs Zone · SSL and Certificates: Edit.

This is not /etc/cipi/cloudflare.ini. That file belongs to certbot's DNS-01 challenge (cipi ssl dns set) and needs Zone.DNS only. The two credentials stay separate.

What enable does — and what it leaves alone

  • Installs the official cloudflared package and runs a locally-managed tunnel under its own systemd unit.
  • Writes Nginx real_ip from https://api.cloudflare.com/client/v4/ips, reading CF-Connecting-IP (/etc/nginx/conf.d/cipi-cloudflare-realip.conf), so logs, fail2ban and your app see the visitor instead of Cloudflare.
  • Adds the same ranges to fail2ban's ignoreip as a backup and, when CrowdSec is already on, to a CrowdSec allowlist.
  • Installs a daily cipi zt refresh cron. A failed fetch keeps the previous list.
  • Does not close 22, 80 or 443.

Hostnames, Access and SSH

Command What it does
cipi zt hostname add <app|--gui> CNAME to {tunnel-id}.cfargotunnel.com, ingress http://127.0.0.1:80. Public — CDN/WAF, no Access. cipi app create does not do this on its own; cipi zt status lists the apps that are not on the tunnel yet.
cipi zt hostname remove <app|--gui> Removes the DNS records and Access applications Cipi created for it, and the ingress rule.
cipi zt access enable <app|--gui> Cloudflare Access in front of that hostname, allowing any authenticated user — tighten the identity provider in the Zero Trust dashboard. If the app has a Git webhook, a Bypass application is created for /cipi/webhook so GitHub, GitLab and Bitbucket are not answered with a 403.
cipi zt access disable <app|--gui> Drops Access; the hostname stays public on the tunnel.
cipi zt ssh enable --hostname= Ingress ssh://127.0.0.1:22 plus an Access SSH application, then prints the ~/.ssh/config block below. Port 22 stays open.
cipi zt ssh disable Removes SSH from the tunnel — reopening port 22 first if it was locked.
ssh config
# ~/.ssh/config on your laptop
Host ssh.example.com
  ProxyCommand cloudflared access ssh --hostname %h
  User cipi
  IdentityFile ~/.ssh/id_ed25519

Deployer still SSHs to localhost, so deploys are not affected by any of this.

Locking the origin

cipi zt lock http [--yes] refuses while any app still renews Let's Encrypt over HTTP-01: validation does not come from Cloudflare's IPs, so those certificates would quietly stop renewing. Move each app to cipi ssl install <app> --dns=cloudflare or cipi zt origin-cert <app> first; --force skips the check if you accept the consequence. Then:

  • if every HTTP hostname — the GUI included — is on the tunnel, 80/443 are closed entirely: the origin goes dark;
  • otherwise 80/443 are allowed only from Cloudflare's ranges, kept current by the daily refresh.

cipi zt lock ssh [--yes] closes port 22 only if cloudflared is active and the tunnel already has SSH ingress; otherwise it would lock you out. The way back is cipi zt ssh unlock. A CrowdSec rescue listener, if enabled, stays reachable.

Lock SSH only after ssh ssh.example.com through cloudflared already works from your own machine. If the tunnel stops while port 22 is closed, the way in is your VPS provider's console — keep it at hand before you run lock ssh.

Origin CA certificates (optional)

cipi zt origin-cert <app> requests a 15-year Cloudflare Origin CA certificate for the app's domains, stores it under /etc/ssl/cipi-origin/<app>/ and swaps the ssl_certificate paths when the vhost already has a :443 block. The app is marked so a later cipi ssl install over HTTP-01 will not overwrite it.

It is not a default replacement for Let's Encrypt. Full (Strict) still needs an origin certificate, but the tunnel talks HTTP to :80 and does not need one.

How it fits the rest of Cipi

  • cipi ssl install over HTTP-01 refuses while lock http is on, and on an app with an Origin CA certificate. Apps on the tunnel get certbot --no-redirect, and cipi ssl force refuses on them: an origin HTTP→HTTPS redirect would break cloudflared.
  • CrowdSec's reverse-proxy check still refuses without real_ip; the error now points at cipi zt enable, after which the check finds the Cloudflare config.
  • Notification triggers: zt_enable, zt_disable, zt_lock_http and zt_lock_ssh.
  • The panel API sudoers grant cipi zt status only. enable, lock and everything else stay with the operator on the CLI.

cipi zt disable reopens UFW 22/80/443, removes the Access applications and DNS records Cipi created, deletes the tunnel, purges cloudflared and removes the real_ip, fail2ban and CrowdSec files and the cron. Fail2ban stays, and your Let's Encrypt certificates are left alone.

cipi scan

Available since v5.2.0, and off by default. It answers one question: did anything change in this app that no deploy put there? A nightly run at 04:40 checks integrity first, then hands the upload directories to ClamAV.

bash
$ cipi scan enable            # nightly integrity + upload ClamAV
$ cipi scan                   # run it now, every app
$ cipi scan myapp             # one app now
$ cipi scan status            # enabled? signatures?
$ cipi scan report            # the last report
$ cipi scan manifest myapp    # rewrite the integrity manifest
$ cipi scan disable           # remove the scanner and its cron

Integrity, then antivirus

The integrity pass compares current/ (or htdocs/ for custom apps) against a sha256 manifest written at every successful deploy and rollback. Then one ClamAV process runs over the upload directories only, with the rfxn PHP-webshell signatures added. Scanning a whole release tree nightly buys noise, not safety — the manifest already covers everything a deploy put there.

Where the manifests live, and why

In /var/lib/cipi/manifests, root:root 0600 — never in the app home. open_basedir gives PHP all of /home/<app>/, so a manifest kept there would be editable by the very webshell the check exists to catch.

The webhook deploy runs as the app user, so it reaches the manifest writer through one sudo entry pinned to its own app name. Every re-baseline is written to events.log: a manifest rewrite with no deploy beside it is exactly the thing to look for.

Filenames that used to hide a webshell

Comparison slices sha256sum records by offset instead of splitting on whitespace. Names containing spaces were being truncated in the report, and GNU coreutils escapes any record holding a backslash or a newline — which a hash-anchored filter dropped entirely. A webshell called sh\ell.php was invisible.

What lands in your inbox

  • scan_integrity — extra or changed files. FPM open_basedir and pool-user drift are reported in the same mail.
  • scan_incomplete — timeouts, failed signature updates, or a release that could not be hashed in full. It is deliberately not called “clean”: a check that did not finish has told you nothing.

Memory floor

cipi scan enable refuses below 2GB free RAM or 3GB free on /var (--force overrides). clamscan loads the entire signature set on every run, and an OOM kill at 04:40 may take MariaDB down with it.

Read the limits honestly. The check tells you a tree changed between deploys. An attacker who owns the app user can still re-baseline through the sudo entry — which is why every re-baseline is logged, and why you read events.log alongside the alert. Per-app Unix users and open_basedir were already Cipi's isolation model; this only verifies it is still in place. A pre-symlink checkpoint and inotify on uploads are not in this release.

cipi compliance

Available since v5.3.1. Cipi cannot be ISO 27001 or SOC 2 certified: there is no organisation or service to audit, only software on your server. What a team under audit needs is evidence that the deploy platform meets the controls, in a form the auditor accepts. cipi compliance collects it.

It is read-only: no check changes the server, runs apt update or writes config. Nothing is installed, scheduled or enabled.

bash
$ cipi compliance                    # all 17 controls: pass / warn / fail / info / n/a
$ cipi compliance tls --days=30      # one control, custom period
$ cipi compliance --json             # exits 1 on any fail — gate CI or cron
$ cipi compliance report             # evidence bundle for the auditor
$ cipi compliance report --days=365 --out=/root/audit --no-archive
$ cipi compliance list               # past reports and their counts
$ cipi compliance controls           # catalog with ISO / SOC 2 mapping

The evidence bundle

cipi compliance report writes /var/log/cipi/compliance/<host>-<UTC time>/:

  • report.md — for the auditor;
  • report.json — for tooling;
  • evidence/<control>/… — the raw command output behind each finding (since 5.4.0 deploys adds deploy-audit.jsonl, deploy-audit.tsv, deploy-audit-chain.txt, deploy-audit-hooks.txt and deploy-audit-unaudited.tsv next to deploys.tsv);
  • SHA256SUMS, plus a .tar.gz of the directory and its .sha256.

Everything is root-only (700/600). The command prints the archive's SHA-256: record it outside the server, so the bundle can later be shown to be unaltered. Each run is logged to cipi.log. --days (default 90) sets the look-back period for tokens, deploys and logs.

Controls

Mapped to ISO/IEC 27001:2022 Annex A and the SOC 2 Trust Services Criteria; cipi compliance controls prints the exact mapping.

Control What it checks
sshEffective sshd -T configuration
firewallufw active with default deny
intrusionfail2ban jails, CrowdSec
patchingunattended-upgrades, pending security updates (from the cached lists), reboot-required
kernelCIS network sysctls
tlsnginx -T protocols, HSTS, Let's Encrypt expiry and key type
accountsUID 0, empty passwords, accounts not managed by Cipi, sudoers
ssh_keysFingerprints; RSA under 3072 bits and DSA flagged
api_tokensNo expiry, expired, unused for the period, * ability, IP allowlist *
gui_2faTwo-factor authentication on GUI accounts
secretsEncrypted, root-only config; key file modes
deploysSince 5.4.0, the deploy audit ledger: fails on a broken hash chain; warns when an app's deploy.php lacks the audit hook, its sudoers lacks the rule, or Deployer's releases_log has a release the ledger never saw. Only releases since auditing began count. Without the ledger (not yet updated) it warns instead of passing
backupsSchedule freshness, off-site copy, encryption
loggingRemote forwarding, auth.log history, journald, auditd
monitoringMonitor cron, disabled checks, alert delivery, muted triggers
timeNTP synchronisation
malwarecipi scan

No secrets in the bundle

Panel SQLite databases are read as their owner with sqlite3 -readonly (PHP PDO fallback), selecting named columns only. Token hashes, password hashes and 2FA secrets are never selected. SSH keys are exported as fingerprints, and backup credentials are not exported.

Honest about gaps. secrets is at best a warn: the vault uses AES-256-CBC without a MAC, which is not authenticated encryption, and the report says so before an auditor has to ask. backups notes that restore tests are not recorded. deploys states that the hash chain proves nothing before the newest record was changed or removed, but root can still rewrite the newest records or the whole file — the syslog copy forwarded off the server is the independent one. Cipi's own design choices appear as notes, not failures: password SFTP login for cipi-apps users, and nginx, databases and PHP kept off unattended-upgrades.

cipi package

Available since v5.2.2, and off by default: neither setup.sh nor cipi self-update installs any of it. Installs host tools a Laravel project may need — image optimisers, ffmpeg, the ImageMagick CLI, pdftotext — from Ubuntu's own repositories. The allowlist is the feature: without it the command is a root apt shell with extra steps. The catalog is closed and every entry has to earn its place — a stateless binary from an Ubuntu repo (no daemon, no port, no credentials, no state outliving the process) with a real Laravel package behind it. Anything failing that is not a package but a service, and belongs to cipi search, cipi db install or the container branch. This is the same rule that put Meilisearch on the other side of the line, written down once.

bash
$ cipi package list
$ cipi package install image-optimizers
$ cipi package install ffmpeg
$ cipi package install imagemagick
$ cipi package install poppler-utils
$ cipi package install webp          # a single binary inside a group is a name of its own
$ cipi package remove ffmpeg

The catalog

  • image-optimizers → jpegoptim optipng pngquant gifsicle webp, exactly the set spatie/laravel-image-optimizer documents.
  • ffmpeg → pbmedia/laravel-ffmpeg. The install prints the warning that matters: run it from a queue worker, never from a web request — the FPM pool is request_terminate_timeout = 300 and one ffmpeg will take every core on a box shared with MariaDB.
  • imagemagick → the convert/magick CLI. This is genuinely missing today: php8.5-imagick depends on libmagickcore/libmagickwand and, through them, on imagemagick-6-common (config files only). The binaries live in imagemagick-6.q16, which nothing in the chain pulls — so PHP-side Imagick works while exec('convert …') does not.
  • poppler-utils → pdftotext, for spatie/pdf-to-text and for feeding PDF content to the Scout indexes above.

A single package inside a group is accepted as a name of its own, so cipi package install webp works alongside install image-optimizers.

Install and remove ask first

install shows what apt intends to do before doing it — package count and disk delta, read from apt-get install -s on the machine rather than from a number hardcoded here — then asks. Afterwards it verifies each expected binary is on PATH and names any that is not, instead of letting the application discover it.

remove purges only the packages actually present (naming an absent one turns a no-op into an apt failure), then previews the orphaned dependencies autoremove would take and asks before running it — server-wide autoremove on a box that also runs MariaDB and PHP is not something to do silently.

Chromium is deliberately not in the allowlist, and the refusal says why. On Ubuntu 24.04 there is no chromium deb at all, and chromium-browser is a 48 kB transitional package whose dependencies are debconf and snapd. Installing it would add a daemon and a snap that updates itself outside apt's control, which is precisely what this command exists not to do. For spatie/browsershot, use Puppeteer's own Chromium (Node 22 is already installed) or Google's apt repository — both deliberate choices, not a side effect of an allowlist entry.
Ghostscript and fonts-dejavu-core are already installed and are not in the list: they arrive as Recommends of php-imagick, and setup.sh passes no --no-install-recommends. PDF through ImageMagick will still fail after installing it, and not for a missing package: imagemagick-6-common ships /etc/ImageMagick-6/policy.xml with the PDF/PS/EPS coders disabled (the Ghostscript CVEs). That file exists on every Cipi server. pdftotext is not affected by the policy, which is part of why poppler-utils earned a place.

Two notification triggers sit under a new Packages category: package_install, package_remove. The panel sudoers file allows package list only: installing packages as root stays with the operator on the CLI.

cipi completion

Since v5.2.0, tab-completion for bash and zsh is installed for you. setup.sh and every cipi self-update write it, and setup.sh also installs the bash-completion package. Nobody edits a dotfile — a new login shell simply has it.

bash
$ cipi app <TAB>               # create  list  show  edit  clone  limits  …
$ sudo cipi dep<TAB>           # → deploy
$ sudo cipi deploy sh<TAB>     # → shop  shop2 (your apps)
$ sudo cipi firewall <TAB>     # → allow  list  attempts
$ cipi help <TAB>              # help topics

# only needed for another user or a custom rc file
$ cipi completion bash
$ cipi completion zsh --print  # write the script to stdout, change nothing

The files written: /etc/bash_completion.d/cipi, /usr/share/zsh/vendor-completions/_cipi (when that directory exists), an /etc/profile.d/cipi-completion.sh loader for every interactive login shell, and — since v5.5.0 — a loader in the ~/.bashrc of root and the cipi user, the two who can run cipi (added once, existing content untouched, interactive shells only).

It completes the top-level verbs, each verb's sub-commands and flags, cipi help <topic>, and app names: from /etc/cipi/apps-public.json when the shell's user can read it (root or the cipi-api group), otherwise from the members of the cipi-apps group, which every user can list.

Where it did not work before 5.5.0

Completion has been installed since 5.2.0, but in three everyday situations <Tab> did nothing. v5.5.0 fixes all three:

  • sudo cipi … — how the cipi user runs every command. It relied on the sudo completion of the bash-completion package; without the package <Tab> offered file names. The script now brings its own for that case (_cipi_sudo_complete): it skips sudo's options, completes cipi as the command even though the binary is root-only, and hands the rest of the line to the cipi completion. With bash-completion loaded nothing changes.
  • Non-login shells — sudo -s, su, tmux, screen. The loader lived only in /etc/profile.d, which those shells do not read; the new ~/.bashrc loader covers them.
  • App names for the cipi user — they came only from /etc/cipi/apps-public.json, which the cipi user cannot read, so sudo cipi deploy <Tab> offered nothing. They now fall back to the cipi-apps group.

New servers get this from setup.sh; existing servers from their next cipi self-update, which rewrites the completion files on every run — no migration step, no package to install. A shell that is already open needs to be reopened. All of the above holds for bash; zsh loads the same completion from its vendor directory and /etc/profile.d.

The completion is hand-written, not framework-generated: tests/verify-5.2.0.sh fails if the verb list drifts from the dispatch table in cipi itself.

cipi service

Check and control the system services that power Cipi directly from the CLI. Nginx uses a graceful reload (zero downtime) instead of a full restart.

bash
$ cipi service list                    # status of all services
$ cipi service list nginx              # status of a specific service
$ cipi service restart                 # restart all services
$ cipi service restart nginx           # graceful reload (zero downtime)
$ cipi service restart php             # restart all PHP-FPM versions
$ cipi service start fail2ban
$ cipi service stop supervisor         # asks for confirmation

# Patch-level upgrades for the blacklisted stack (v5.2.3+)
$ cipi service upgrade                 # list installed vs apt candidate
$ cipi service upgrade valkey [--yes]  # 'all' is refused on purpose

# Structured output (v5.0.6+) — panel API
$ cipi service list --json

REST: GET /api/services, POST /api/services/{name}/restart (API 1.15.0+ / Cipi 5.0.6+; abilities services-view / services-manage).

Supported service names: nginx, mariadb, postgresql (aliases: pgsql, postgres — when installed via cipi db install pgsql), valkey-server, supervisor, fail2ban, php<ver>-fpm (e.g. php8.5-fpm). The keyword php targets all installed PHP-FPM versions at once. For the cache backend, redis-server, redis, and valkey are accepted as aliases of valkey-server. Since v5.2.2, meilisearch (alias search) appears only when cipi search has installed the unit. Since v5.3.0, cloudflared appears the same way once cipi zt enable has installed it.

Valkey — the BSD-licensed, Redis-compatible fork — is included in the default stack and replaces redis-server since Cipi 4.5.6. It is installed with a password, bound to localhost only, and its credentials (user, password) are saved in /etc/cipi/server.json and shown at the end of installation. valkey-server is added to the unattended-upgrades blacklist — Cipi manages it, so it is not auto-upgraded automatically. Since v5.2.3, cipi service upgrade valkey is how you apply its patches — see manual stack upgrades. See the Valkey section for details and the automatic Redis → Valkey migration.

Manual stack upgrades — nginx, MariaDB, PostgreSQL, Valkey

Available since v5.2.3. Nginx, MariaDB, PostgreSQL and Valkey are deliberately kept off unattended-upgrades: a MariaDB restart is not a 4am surprise. PHP has had cipi php upgrade (daily at 03:30 since v5.6.3) for a while; this is the operator-facing equivalent for the rest of the blacklisted stack — still manual, still patch-level only.

bash
# nginx — apt --only-upgrade of installed nginx*, nginx -t, then reload
$ cipi nginx upgrade [--yes]

# databases — one engine, or every installed engine when omitted
$ cipi db upgrade [mariadb|pgsql] [--yes]

# the generic entry, and the home for Valkey
$ cipi service upgrade [nginx|mariadb|postgresql|valkey] [--yes]

# no name: list installed versions against the current apt candidate
$ cipi service upgrade
Command What it does
cipi nginx upgrade [--yes] apt --only-upgrade of the installed nginx* packages from the nginx.org mainline repo Cipi already configured, then nginx -t and a reload. /etc/nginx/nginx.conf is kept (--force-confold) — this is not the HTTP/2-bomb rewrite.
cipi db upgrade [engine] [--yes] The same for MariaDB / PostgreSQL. Without an engine argument it upgrades every installed engine. The prompt says plainly that there will be brief downtime.
cipi service upgrade [name] [--yes] The generic entry point, and the only way to upgrade Valkey. With no name it lists installed versions against the current apt candidate. all is refused — that is exactly what the blacklist exists to prevent — and php is pointed at cipi php upgrade.

How it stays scoped

All three share lib/stack-upgrade.sh, which pins the package patterns (^nginx(-|$), ^mariadb-, ^postgresql, ^valkey) so a loose match cannot drag PHP in. It re-asserts the nginx.org and MariaDB.org repositories if they were wiped, and holds /run/cipi-stack-upgrade.lock so two upgrades cannot overlap.

On success, and when SMTP is configured, you get a mail: nginx_upgrade, mariadb_upgrade, pgsql_upgrade or valkey_upgrade (disable any of them with cipi notifications disable <trigger>).

These commands are not on a cron and not on the panel. setup.sh and cipi self-update do not run them, and /etc/sudoers.d/cipi-api does not grant them — the same rule as cipi package install. Upgrading a database engine is an operator decision, taken while you are watching.

cipi ssh — SSH Key Management

Manage the authorized SSH keys for the cipi user — the admin SSH entry point. The cipi user (group cipi-ssh) uses public-key only; root login is disabled. App users (group cipi-apps) connect with password — see SSH as the app user. Since v5.5.0, cipi ssh apps can keep app users out of SSH and SFTP, one app or all of them.

Commands

bash
$ cipi ssh list                 # list all authorized keys with fingerprint, comment, and current-session marker
$ cipi ssh add [key]             # add a new SSH public key (validates format, prevents duplicates)
$ cipi ssh remove [n]            # remove a key by number
$ cipi ssh rename [n] [name]     # change the display name / comment of a key

# Structured output (v5.0.6+) — panel API
$ cipi ssh list --json

REST: GET|POST /api/ssh/keys, DELETE /api/ssh/keys/{n} (API 1.15.0+ / Cipi 5.0.6+).

Safety mechanisms

cipi ssh remove includes two safeguards to prevent lockout:

  • Current-session protection — you cannot remove the key used by your active SSH session.
  • Last-key protection — you cannot remove the last remaining authorized key.

Key comments

SSH keys are stored with their original comments intact, making it easy to identify who each key belongs to. Use cipi ssh rename to change the display name of any key:

bash
# list keys to find the number
$ cipi ssh list

# rename key #2
$ cipi ssh rename 2 "john-macbook"

Email notifications

When SMTP is configured, Cipi sends an email alert every time a key is added, removed, or renamed. The notification includes the server hostname, IP address, key fingerprint, key comment, timestamp, and remaining key count. Rename notifications also include the old and new key name.

cipi ssh apps — SSH/SFTP access of app users (v5.5.0+)

Every app has its own Linux user, which can log in over SSH and SFTP with its password. Up to 5.4 there was no way to turn that off short of editing sshd_config by hand. Where nobody needs it, turn it off — for one app or for all of them:

bash
$ cipi ssh apps                     # every app user: enabled or disabled, and what new apps get
$ cipi ssh apps --json
$ cipi ssh apps disable shop        # no SSH/SFTP from outside for shop
$ cipi ssh apps enable shop
$ cipi ssh apps disable --all       # every app user, and the apps created from now on
$ cipi ssh apps enable --all        # everyone back, whatever the single settings were
  • Disabled means no login from another machine. Logins from the server itself stay allowed, because Deployer reaches the app user over ssh localhost: deploys, webhooks and rollbacks keep working. Sessions already open stay open until they close.
  • --all changes every app user, whatever the single settings were, and sets what apps created afterwards get (cipi app create, cipi sync import). After a disable --all, single users can still be enabled one by one.
  • How. The state is membership of the group cipi-nossh. One rule at the end of /etc/ssh/sshd_config — Match Group cipi-nossh Address *,!127.0.0.1,!::1 → DenyUsers * — denies that group from every non-local address. It is added the first time a user is disabled, on a copy that sshd -t validates before it replaces the live file, followed by a reload, not a restart; a rejected copy changes nothing. After that, enabling or disabling a user is only a group change, with no rewrite and no reload.
  • After a disable the command asks sshd itself (sshd -T -C) whether the rule applies to the user from outside and not from localhost, and warns if it does not.
  • The cipi user and root are refused by the command — the cipi user's access is managed with the key commands above.

Default: every app user has access, on new and existing servers. Nothing changes until the command is used. Changes are logged and notified (trigger app_ssh_access). CLI only (root).

cipi — Server & Self-Update

Top-level commands for server status and Cipi self-management.

bash
$ cipi status              # CPU, RAM, disk, services, PHP versions, apps
$ cipi disk                # disk usage of the server and of every app (v5.5.0+)
$ cipi version             # show installed Cipi version
$ cipi self-update         # update Cipi to the latest version
$ cipi self-update --check # check for updates without installing

Password & credential reset

Cipi provides commands to regenerate server-level passwords. New passwords are stored in /etc/cipi/server.json (encrypted via Vault) and displayed on screen. Save them immediately — they are shown only once.

bash
$ cipi reset root-password              # regenerate the root Linux user SSH password
$ cipi reset db-password [--engine=…]   # regenerate root password (default or chosen engine)
$ cipi reset valkey-password           # regenerate the Valkey password and restart the service
cipi reset valkey-password (alias cipi reset redis-password) restarts the Valkey service. Connected clients will be temporarily disconnected. If your apps use Valkey for cache or sessions, expect a brief interruption.