Infrastructure
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 — typically the ondrej/php PPA on 24.04, packages.sury.org on 26.04 when Launchpad has no suite, or Ubuntu main as a last resort (co-installable 8.3–8.5 where the chosen repo supports it).
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>.$ cipi php list # list installed versions, status, and 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 to all installed PHP packages
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).
A weekly check runs automatically on Sunday at 03:30 via root crontab (wrapped by
cipi-cron-notify). Log:
/var/log/cipi/php-upgrade.log. Existing servers receive the cron on
cipi self-update (migration 4.7.13).
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:
$ 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.
$ 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_filesizealso raisespost_max_sizeandmemory_limitwhen they would otherwise limit it, and Nginx'sclient_max_body_sizeis 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,extensionand 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.
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 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+)
$ 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
$ cipi db create # interactive $ cipi db create --name=analytics # non-interactive (default engine) $ 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=…]
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 alias
Add multiple domains or subdomains to any app. After adding aliases, run
cipi ssl install to provision or renew the certificate with SAN coverage for all
domains. Since v4.8.0, cipi alias add|remove regenerates the vhost
and re-applies SSL with certbot install --redirect so HTTPS is not dropped.
$ 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, and
issue the certificate with
cipi ssl install myapp --dns=cloudflare --wildcard: HTTP-01 cannot validate a
wildcard.
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.
$ 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 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.
$ 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 manages Let's Encrypt certificates. Certificates auto-renew via a weekly cron.
cipi ssl status shows expiry dates with color-coded warnings: green (>30 days), yellow
(14–30 days), red (<14 days).
$ 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 # show all certs with expiry dates # DNS-01 via Cloudflare (v5.0+) — wildcard certs $ cipi ssl dns set --provider=cloudflare --token=YOUR_CF_TOKEN $ cipi ssl install myapp --dns=cloudflare $ cipi ssl install myapp --dns=cloudflare --wildcard
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.
cipi alias add, always run
cipi ssl install again to provision a new SAN certificate covering all domains.
If HTTPS redirect was lost after a vhost change, use cipi ssl force instead of
re-issuing.
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).
$ 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 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.
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 S3 credentials shared by every profile. Since
v5.1.0 you may leave the bucket empty for local-only backups,
which land under /var/backups/cipi.
$ 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 |
2 — Backup profiles
Create a profile once; it then runs on its own schedule. Two typical ones:
# 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
$ 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
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.
$ cipi backup key show # print the key — store it off-server $ cipi backup key rotate # new key for future runs
--encrypt, and remember that runs taken
before a key rotate still need the old key.4 — Running, checking and restoring
$ 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 fetchno longer reports success on a prefix that holds no objects, so a mistyped timestamp is an error rather than an empty directory.cipi backup verifysays plainly that there is nothing to verify when no run has happened yet, instead of “passed”.tarwarnings — “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
Encryptedis displayed correctly.
To restore, fetch the run and feed the pieces back:
$ 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.
<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>/
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.
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.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:
# 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
# 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:
$ su - myapp
myapp@server:~$ crontab -e
Example entries you might add:
# 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
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.
/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
# 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).
$ 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.
$ 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.
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:
# 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
|
$ 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.
$ 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.
$ 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.
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.
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.
$ 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
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. A dedicated recidive jail bans repeat offenders for 7 days after 3 bans
within 24 hours.
$ 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.
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.
$ 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_fromandreal_ip_header. Every log line would carry your CDN's address, so CrowdSec would ban Cloudflare and leave the attacker alone.--forceoverrides it if you know better.
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
.hookskey ofapi.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.
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.
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 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.
$ 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. FPMopen_basedirand 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.
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 search
Available since v5.2.2, and off by default: neither
setup.sh nor cipi self-update installs it. A self-hosted search engine
for Laravel Scout, wired the way the rest of Cipi is: native, not a container.
Meilisearch is a single static Rust binary with no runtime dependencies, so it installs as a
binary, a config file and a systemd unit alongside Nginx, PHP-FPM, MariaDB and Valkey. Engines
that need a runtime around them (Elasticsearch, Typesense, Qdrant) belong in the container
branch; this one does not, and that is the line. For a Laravel app it is
cipi search enable <app> plus a scout:import — no Algolia
account, no per-record billing.
You may not need it. For a small dataset Scout's database driver — or Postgres
full-text, already in the stack since 5.0 — needs no extra service, no extra memory and no
upgrade path. cipi search status says so on a server where it is not installed.
$ cipi search install [--version=] [--port=] [--force] $ cipi search enable <app> $ cipi search disable <app> [--purge-indexes] $ cipi search list $ cipi search status [--check] [--json] $ cipi search key show <app> $ cipi search key rotate <app> $ cipi search key rotate --master $ cipi search upgrade [--version=] [--reset-data] [--yes] $ cipi search remove [--keep-data]
What install actually does
Downloads the release binary for this architecture from GitHub (amd64/aarch64), creates the
meilisearch system user, /var/lib/meilisearch (0750),
/etc/meilisearch.toml and a hardened unit, then waits on
GET /health before claiming success. It listens on 127.0.0.1
only — never a public address, so there is no firewall hole to open and no TLS to terminate.
Meilisearch publishes no checksum beside its binaries, so the download is verified the only
way available: it is executed and asked for its version before it is allowed to replace
anything.
install refuses below 512MB MemAvailable
(--force overrides) and warns below 1GB. Meilisearch memory-maps its LMDB store,
so its RSS understates what it asks the kernel for; on a 1GB VPS already running MariaDB
tuned to that RAM, an OOM kill is a real outcome and the kernel does not always pick
Meilisearch.
Isolation is the design
Meilisearch has no databases and no per-tenant separation, so one shared instance needs an
explicit boundary. Cipi's is the index prefix, and Cipi writes it — the operator
does not choose it: two apps that pick the same index name are not separated by a
key pattern. It holds because a Cipi app username is
^[a-z][a-z0-9]{2,31}$ with no hyphens, so
blog- can never be a prefix of blogs- or the reverse, and every
index name belongs to exactly one app. An app that ignores SCOUT_PREFIX does
not collide with its neighbour — it gets a 403. enable still asserts the
non-overlap before minting a key.
What enable writes
Mints an API key scoped to the index pattern <app>-* with exactly the
actions Scout calls (search, documents.*, indexes.*,
settings.get, settings.update, tasks.get,
stats.get — not keys.*), then writes
SCOUT_DRIVER, SCOUT_PREFIX=<app>-,
MEILISEARCH_HOST and MEILISEARCH_KEY into
shared/.env. The previous SCOUT_DRIVER is recorded so
disable can put it back.
/etc/cipi/search.json) and in
/etc/cipi/meilisearch.env, mode 0600 root:root, read by
systemd as root before the unit drops to the meilisearch user.
--master-key on an ExecStart line would put it in
/proc/<pid>/cmdline, which every app user on the box can read — the
same reason cipi search's own HTTP calls pass the
Authorization header to curl through a stdin config file instead of
-H.Apps, clones, and the master key
cipi app delete revokes the app's key and drops its indexes. The engine knows
nothing about apps.json: without this, deleting an app left a live, working API
key behind for a Unix user and a home that no longer exist.
cipi app clone gives the clone its own key and prefix. The source's
MEILISEARCH_* and SCOUT_PREFIX are excluded from the copied
.env for a sharper reason than the Reverb exclusion next to it: the source's
key is valid for the source's prefix, so a clone that inherited it would not fail —
it would reindex straight into production's indexes.
Rotating the master key regenerates every API key on the server. Meilisearch derives a key's
value from its uid and the master key, so the uids survive and the values do not.
key rotate --master therefore rewrites every enabled app's .env
in the same run — otherwise the whole server loses search at once, silently, at the next
master key change. It says which apps it is about to touch before it starts.
Upgrades are the real cost
A Meilisearch store is readable only by the version that wrote it.
cipi search upgrade asks the new binary which in-place upgrade flag it
supports (--upgrade-db on current releases,
--experimental-dumpless-upgrade on older ones) rather than guessing from a
version number, applies it through a one-shot systemd drop-in, and then restarts
without it to prove the unit still comes up on its own. If the engine
refuses the store, the old binary is put back and the service restarted — nothing is
dropped without an explicit answer. Only if you say so does it delete
data.ms, reissue every app key (a wiped store has no keys, so every
.env would otherwise be dead) and print the scout:import
commands.
Meilisearch appears in cipi status,
cipi service list|start|stop|restart (meilisearch, or
search) and shell completion, but only where the unit exists. Six notification
triggers sit under a new Search category:
search_install, search_enable, search_disable,
search_key_rotate, search_upgrade,
search_remove.
The panel sudoers file allows search status|list|enable|disable.
install, upgrade, key rotate and
remove are deliberately not in it: they change or destroy
server state and stay with the operator on the CLI.
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.
$ 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 setspatie/laravel-image-optimizerdocuments.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 isrequest_terminate_timeout = 300and one ffmpeg will take every core on a box shared with MariaDB.imagemagick→ theconvert/magickCLI. This is genuinely missing today:php8.5-imagickdepends onlibmagickcore/libmagickwandand, through them, onimagemagick-6-common(config files only). The binaries live inimagemagick-6.q16, which nothing in the chain pulls — so PHP-side Imagick works whileexec('convert …')does not.poppler-utils→pdftotext, forspatie/pdf-to-textand 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 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 20 is
already installed) or Google's apt repository — both deliberate choices, not a side
effect of an allowlist entry.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.
$ cipi app <TAB> # create list show edit clone limits … $ cipi deploy my<TAB> # app names, from /etc/cipi/apps-public.json $ cipi help <TAB> # help topics # only needed for a non-login shell or a custom rc file $ cipi completion bash $ cipi completion zsh --print # write the script to stdout, change nothing
Three files are written: /etc/bash_completion.d/cipi,
/usr/share/zsh/vendor-completions/_cipi (when that directory exists), and an
/etc/profile.d/cipi-completion.sh loader that fires for every interactive bash and zsh
shell — with or without the bash-completion package.
It completes the top-level verbs, each verb's sub-commands and flags, and
cipi help <topic>. App names are filled in from
/etc/cipi/apps-public.json when the shell's user can read it — a root session, or a
member of the cipi-api group; everything else is a static word list that works for any
user. sudo cipi <TAB> resolves through the stock sudo completion.
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.
$ 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 # 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.
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. See the Valkey section for details
and the automatic Redis → Valkey migration.
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.
Commands
$ 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:
# 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 — Server & Self-Update
Top-level commands for server status and Cipi self-management.
$ cipi status # CPU, RAM, disk, services, PHP versions, apps $ 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.
$ 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.