cipi-sh/cipi

Easy Laravel Deployments

Shell

1,145

999 commits

updated Oct 15, 2026

See the code

README

Cipi logo

Cipi

Easy Laravel Deployments
One command installs a complete production stack. One command deploys your app.
No panel, no bloat — so you can focus on what you love: building your application.

Website · Docs · Report a bug


What is Cipi?

Cipi turns any Ubuntu VPS into a multi-app PHP hosting platform — Laravel by default (PHP-FPM or optional Octane/FrankenPHP), with full isolation, zero-downtime deploys, SSL, queue workers, and S3 backups — all managed from a single CLI. Use --custom for simple sites: classic deploy (no releases/shared), configurable docroot and Nginx (try_files, entry point), no DB or cron.

No web panel. No bloat. No sleepless nights fighting Nginx configs or PHP-FPM pools.
Just SSH and the cipi command.

wget -O - https://cipi.sh/setup.sh | bash

Run this on the server, over SSH — not on your own machine — as a user with sudo.

Works on DigitalOcean, AWS EC2, Hetzner, Vultr, Linode, OVH, Google Cloud, Scaleway, and more.


From zero to production in 3 steps

1. Install Cipi on a fresh Ubuntu 24.04 or 26.04 VPS (~10 minutes). SSH into the server first and run it there, as a user with sudo:

wget -O - https://cipi.sh/setup.sh | bash

The installer asks for the public half of an SSH key (~/.ssh/id_ed25519.pub on your own machine; ssh-keygen -t ed25519 creates one if you have none — on Windows, from PowerShell or WSL). It ends by printing a root password: keep it in a password manager. Root is reachable only by logging in as the cipi user with that key and then running su root.

2. Create your app (Laravel by default, or cipi app create --custom for a simple deploy):

cipi app create
# username, domain, git repo, branch, PHP version
# → Laravel: user, DB, Nginx, workers, cron, webhook
# → Laravel Octane: cipi app create --octane
# → Custom: user, Nginx, PHP-FPM; Git optional (empty = SFTP-only to ~/htdocs)

3. Deploy and go live:

cipi deploy myapp
cipi ssl install myapp

That's it. Your Laravel app is live.


Stack

Every app gets a fully isolated environment. Laravel (default): zero-downtime deploy, DB, workers, cron, webhook — optionally Octane (FrankenPHP) instead of PHP-FPM. --custom: classic deploy into htdocs, configurable docroot only; Nginx uses index index.html index.php, try_files $uri $uri/ /index.php?$args, error_page 404 /404.html. No DB, no .env, no cron, no workers.

ComponentDetails
Web serverNginx reverse proxy with per-app virtual hosts — PHP-FPM or Octane (proxy_pass), optimized for Laravel
PHP & ComposerSelectable per app — PHP 7.4 to 8.5, hot-swappable
RuntimePHP-FPM pools by default; optional Laravel Octane (FrankenPHP) per app (--octane)
DatabaseMariaDB (default) + optional PostgreSQL; dedicated DB and user per Laravel app
SearchOptional Meilisearch for Laravel Scout — native binary, loopback only, per-app scoped key + index prefix
Queue workersSupervisor with per-app pools — queue:work or Horizon; optional Reverb for WebSockets (wss:// + credentials + fd limits)
DeploymentsDeployer — Laravel: atomic symlink, 5 releases, rollback, optional Node build; Custom: clone into htdocs
SSLLet's Encrypt via Certbot — HTTP-01 by default; optional DNS-01 (Cloudflare) + wildcards
SecurityFail2ban + UFW, optional CrowdSec (firewall bouncer), optional Cloudflare Zero Trust (cipi zt: tunnel, Access, origin dark), and nightly integrity/upload scan, per-app Linux user + PHP-FPM/Octane + SSH key
HealthchecksHTTP probes every 5 minutes, plus a post-deploy check with optional automatic rollback of a broken release
BackupsBackup profiles: what, how often, where, how long — S3/S3-compatible/local, client-side encryption
FrontendsNode apps: SPA and static sites served by nginx, SSR (Next, Nuxt, SvelteKit, Astro, Remix) with blue/green deploys, per-app Node version
Configurationcipi ini for php.ini; optional per-project cipi.yml for aliases, www, basic auth, redirects and proxies, search, databases, workers, Reverb, limits, deploy recipe, forced HTTPS, required .env names, scheduled commands, backups and post-deploy steps

Features

🔒 Security & Isolation by Design

Each app runs under its own Linux user with an isolated filesystem, PHP-FPM pool (or Octane process), and database. A compromise in one app cannot touch the others. Configs are encrypted at rest with AES-256 (Vault). GDPR-compliant log rotation included. Per-app resource limits (cipi app limits) cap FPM children, memory, Octane workers, and queue processes. cipi app fix-permissions restores that layout if a deploy or a zip-as-root left the home unreadable.

Brute-force protection is on from the first boot. UFW denies all inbound traffic except SSH, HTTP and HTTPS, and fail2ban bans the addresses that keep failing SSH logins. These are the defaults:

SettingDefault
Failed SSH logins before a ban3
Counted over1 hour
Ban24 hours, longer for each repeat, up to 7 days
Repeat offenders3 bans in 24 hours → banned for 7 days

The number of failed logins can be changed; the rest stays as above:

cipi firewall attempts            # current threshold, counting window and ban time
cipi firewall attempts 5          # ban after 5 failed SSH logins (1–100)
cipi firewall attempts default    # back to 3
cipi ban list                     # who is banned right now
cipi ban unban <IP>

The new value is checked with fail2ban-client -t and then applied with a fail2ban reload; a configuration fail2ban refuses is rolled back. It is stored in /etc/fail2ban/jail.d/cipi-attempts.local, so Cipi updates do not reset it.

App users can be kept out of SSH. Every app has its own Linux user, which can log in over SSH and SFTP with a password. Where nobody needs that, turn it off, for one app or for all of them:

cipi ssh apps                     # every app user: access enabled or disabled
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. Deploys are not affected: Deployer reaches the app user over ssh localhost, which stays allowed. The cipi user is never touched by these commands. By default every app user has access.

Optional, off until you turn them on — setup.sh / self-update never install them:

cipi crowdsec enable    # IP reputation → firewall bouncer (not a WAF)
                        # includes a TLS rescue URL: one GET allowlists your IP
cipi zt enable          # Cloudflare Zero Trust: tunnel + real_ip, ports stay open
                        # then hostname / Access / lock http / lock ssh as you choose
cipi scan enable        # nightly: release integrity + ClamAV on uploads

Ubuntu security updates land daily via unattended-upgrades. PHP patch releases are applied every Sunday by cipi php upgrade. Nginx, MariaDB, PostgreSQL and Valkey are left alone until you ask — restarting a database at 04:00 is not a surprise Cipi will create:

cipi nginx upgrade                 # nginx.org mainline, config test, reload
cipi db upgrade                    # MariaDB, and PostgreSQL if installed
cipi db upgrade pgsql --yes
cipi service upgrade valkey

Patch-level only (apt --only-upgrade of what is already installed). Cipi configs are kept. --yes skips the prompt.

⚡ Zero-Downtime Deploys

Deployer clones your repo, runs composer install, links storage, runs migrations, and swaps the symlink atomically. Optional Node build on deploy (cipi app edit --node-build=…). Roll back to any of the last 5 releases instantly. Opt-in pre-deploy DB snapshot (cipi deploy --snapshot).

Every deploy is recorded in a root-owned, hash-chained audit ledger (/var/log/cipi/deploys.jsonl, also sent to syslog), whatever started it: cipi deploy, the Git webhook, cipi/agent, the panel, or dep deploy run by hand. Each record has the release, the commit, and who started the deploy, read by root from the process chain: origin, login user and SSH address. cipi deploy <app> --audit shows it.

💾 Backups That Match How You Actually Work

Backups are driven by profiles: each one decides what it takes (application files, databases, or both), which apps and databases it covers, how often it runs, where it lands and how long it is kept. A 30-minute database-only copy kept on disk sits happily next to a nightly full copy encrypted to S3.

cipi backup profile add hourly-db --scope=db \
    --databases='shop,tenant_*' --exclude-tables='*.jobs,*.telescope_*' \
    --every=30m --keep=48 --dest=local

cipi backup profile add nightly --scope=all \
    --cron='0 2 * * *' --keep-days=14 --dest=s3 --encrypt

Databases are discovered from the engine, so tenant databases an app creates at runtime are backed up too. The schedule is written to root's crontab for you. cipi backup verify checks the newest run actually opens, and an overdue profile raises an alert — a backup that quietly stopped running is worse than none, because it still looks configured.

📄 cipi.yml — Configuration That Travels With the Code

An app can carry a cipi.yml in its repository describing the state it expects: domain aliases, the www redirect, HTTP basic auth, redirects and prefix proxies, Meilisearch for Scout, PHP version, settings and limits, its extra databases, its queue workers, its healthcheck, its deploy recipe options (keep_releases, the artisan hooks, predeploy_snapshot, and node_build — the asset build, which runs before the release goes live), the forced HTTPS redirect, the .env variables it requires (names only, never values), its scheduled commands (crons), its backup strategy, and post-deploy steps (deploy.post). cipi yml plan shows exactly what would change and cipi yml apply applies it.

Server reconciliation (aliases, PHP, workers, databases, …) is opt-in: cipi yml auto <app> on makes every successful deploy run cipi yml apply (from cipi deploy or the Git webhook). A release without the file is a no-op.

deploy.post is different — it runs after every successful deploy as soon as the section is in the committed file, with no auto switch, and only once the release is already live. It is not where the asset build goes. Steps use allowlisted runners only (artisan, npm, composer, php, node, …); no shell, no pipes. After Deployer, optional cipi yml apply, then deploy.post, then the post-deploy healthcheck.

deploy:
  node_build: "npm ci && npm run build"   # before the release goes live
  # post:
  #   - artisan cache:clear               # after the release is live
  # post_on_failure: abort   # default warn — log + email, release stays live
app:
  www: to-root                 # www → apex
  basic_auth:
    users: [admin]             # keeps the password set on the server
  limits:
    memory_limit: 512M         # cipi app limits, same bounds as the CLI
redirects:
  - from: /blog/
    to: "https://blog.example.com/"
proxies:
  - prefix: /api/
    upstream: "http://127.0.0.1:3000"
    strip_prefix: true
ssl:
  force_https: true            # needs a certificate; only ever turned on here
env:
  required: [STRIPE_KEY]       # names the .env must carry — plan blocks if missing
crons:
  - every: 30m                 # same allowlisted runners as deploy.post
    run: artisan queue:prune-batches
deploy:
  keep_releases: 3             # cipi app deploy-config, from the repository
  migrate: false
  predeploy_snapshot: true     # DB snapshot before each deploy
  node_build: "npm ci && npm run build"
cipi yml generate myapp --save       # writes /home/myapp/cipi.yml
cipi yml generate myapp > cipi.yml   # or print it
cipi yml example myapp > cipi.yml    # or a commented template
cipi yml plan myapp                  # server diff + "After deploy" steps
cipi yml post-deploy myapp           # run deploy.post now (test)

It can only configure an app that already exists, its databases must be named <app> or <app>_*, its backup profiles <app> or <app>-*, and its healthcheck URL one of the app's own domains — so a commit can never reach beyond its own app. Passwords never go in the file: a basic auth user listed by name keeps the password already on the server, and only bcrypt or SHA-512 crypt hashes are accepted. Proxies declared in the file cannot publish Cipi's own local services (databases, Valkey, another app's Octane/Reverb) or link-local and cloud metadata addresses, even through a hostname that resolves to one. crons entries go through the same allowlisted runners as deploy.post — never a free-form shell line — and only replace the crontab entries the file manages, tagged # cipi-yml. app.limits values outside the CLI's bounds block the plan instead of being clamped, and ssl.force_https can only ever be turned on from the file. A backup: section is the list of profiles this file owns: one it no longer names is removed from the schedule (the archives stay until cipi backup prune). A profile added with cipi backup profile add is not touched unless the file names it. deploy.snapshot is still read; the name to write is predeploy_snapshot.

⚛️ Node Frontends: SPA, Static and SSR

React, Vue, Svelte, Next.js, Nuxt, SvelteKit, Astro and Remix get the same flow as Laravel: git push, releases, rollback, webhooks, SSL, basic auth, redirects, audit ledger.

cipi app create --user=shop --domain=shop.com [email protected]:acme/shop.git --framework=next
cipi app create --user=docs --domain=docs.com [email protected]:acme/docs.git --node=static --output=dist
cipi app create --user=dash --domain=dash.com [email protected]:acme/dash.git --framework=vite
  • spa: built on deploy and served by nginx. Unknown paths fall back to index.html, fingerprinted assets are cached as immutable, HTML is revalidated.
  • static: pre-rendered output (Astro, nuxt generate, Next output: 'export') with real 404s.
  • ssr: a Node server on localhost behind nginx, with blue/green deploys. The new release starts on the idle slot and must answer on its health path before nginx switches to it; the old process stops only after that. A release that never comes up fails the deploy and the old one keeps serving. cipi node restart <app> uses the same switch.

Dependencies are installed exactly as locked (npm ci, pnpm, yarn or bun, detected from the lockfile), and the build runs with NODE_ENV=production. shared/.env is linked into every release, so it works for build-time variables (VITE_*, NEXT_PUBLIC_*) and runtime secrets alike. Presets (--framework=next|nuxt|sveltekit|astro|remix|vite) fill in the mode, build, start command and output folder; --build, --start, --output and --health-path override them.

Node per app, from nodejs.org. --node-version=22|24 installs the official build into /opt/cipi/node/<major>, verified against the release's SHA-256 list, with corepack for pnpm and yarn. Apps on different majors run side by side. cipi node upgrade --restart moves every app to the latest patch with no downtime.

Laravel apps too. cipi node default 24 makes that major the server-wide Node for Laravel asset builds (--node-build), cipi app run npm and cipi.yml post-deploy steps, overriding the system Node from NodeSource without uninstalling it (cipi node default system goes back). One app that needs something else keeps its own: cipi app edit legacy --node-version=22.

The same settings can live in the repository as a node: section of cipi.yml (framework, mode, version, build, start, output, health_path). With cipi yml auto <app> on, each deploy reads them from the commit being deployed, before the build, so a change ships together with the code that needs it.

Git webhooks work without cipi/agent: Cipi verifies GitHub/Bitbucket HMAC signatures and GitLab/Azure tokens itself, in a PHP pool that can do nothing but queue the app's own deploy. The start command runs as an argument list, never through a shell. .env is read as data, not sourced, and the server binds to 127.0.0.1 on a port Cipi assigns.

🚀 Laravel Octane (FrankenPHP)

Serve Laravel via Octane instead of PHP-FPM — same server, side by side with classic apps:

cipi app create --octane
cipi app convert myapp --to=octane   # or --to=fpm

Nginx proxies to a localhost Octane port; Supervisor runs ${app}-octane. Requires laravel/octane in the app repo (starts after the first successful deploy).

📡 Reverb, Horizon & Scheduler

First-class Laravel extras, all CLI-managed:

cipi app reverb enable myapp
cipi worker horizon enable myapp
cipi schedule on myapp

Enabling Reverb allocates a localhost port, writes a Supervisor program, and proxies the two paths the Pusher protocol uses — /app/{key} for the WebSocket and /apps/{id}/… for the broadcast API — through Nginx on the app's own domain. It also generates REVERB_APP_ID/KEY/SECRET and the VITE_REVERB_* copies the frontend build reads, picks ws:// or wss:// from whether the app has a certificate (and moves to wss:// by itself when one is installed later), and raises the file-descriptor limits that otherwise cap a WebSocket server at about a thousand concurrent clients.

An app with its own /app or /apps route cannot use Reverb behind the same domain — those paths belong to the protocol. Reverb can also be declared in cipi.yml as workers.reverb: true.

Horizon is mutually exclusive with queue:work workers. Scheduler toggles the crontab schedule:run entry.

🔎 Meilisearch for Laravel Scout (optional)

A self-hosted alternative to Algolia, off by default and never installed by setup.sh or cipi self-update:

cipi search install          # binary + systemd unit on 127.0.0.1:7700
cipi search enable myapp     # scoped key + Scout settings in the app .env

Meilisearch is a single static Rust binary, so it installs natively like the rest of the stack — no Docker in the loop. enable writes SCOUT_DRIVER, MEILISEARCH_HOST, MEILISEARCH_KEY and SCOUT_PREFIX into shared/.env; then composer require laravel/scout meilisearch/meilisearch-php and php artisan scout:import "App\Models\Post" in the app.

One instance, real isolation. Meilisearch has no per-tenant databases, so Cipi gives every app an API key scoped to the index pattern <app>-* — with only the actions Scout calls, never key management — and writes SCOUT_PREFIX itself rather than leaving it to you. Cipi usernames contain no hyphens, so blog- can never overlap blogs-: an app that ignores the prefix gets a 403 rather than its neighbour's documents. The master key stays in the vault and in a root-only EnvironmentFile, never in an app .env and never on a command line.

cipi search key rotate <app> reissues one key; --master rotates the master key and rewrites every app's .env in the same run (Meilisearch derives each key from the master, so they all change at once). cipi search upgrade swaps the binary and does the in-place store upgrade, rolling the binary back if the engine refuses — and only dropping the indexes if you say so, since scout:import rebuilds them. Indexes are derived data, so they are deliberately not part of cipi backup.

For a small dataset you may not need any of this: Scout's database driver, or Postgres full-text, needs no extra service.

📦 Optional host tools

Some projects need a binary on the host — an image optimiser, ffmpeg, pdftotext. cipi package installs them from Ubuntu's own repositories, from a closed allowlist:

cipi package list
cipi package install image-optimizers   # or ffmpeg, imagemagick, poppler-utils
cipi package remove ffmpeg

The allowlist is the feature: without it this would be a root apt shell with extra steps. An entry has to be a stateless binary from an Ubuntu repo — no daemon, no port, no credentials, no state that outlives the process — with a real Laravel package behind it. Anything else is a service, and goes through cipi search, cipi db install or the container branch. Chromium is the instructive rejection: on Ubuntu 24.04 chromium isn't a deb at all and chromium-browser is a transitional package that depends on snapd, so "just apt-install it" would quietly add a self-updating daemon.

install shows what apt actually intends to pull — package count and disk — and asks before running. remove purges only what is present, then shows which orphaned dependencies autoremove would take before touching them.

Already in the base stack, so not in the list: the Imagick PHP extension, Ghostscript and fonts-dejavu-core (Recommends of php-imagick), and Node 22 (cipi node default changes it).

🔗 Webhook Auto-Deploy

Native GitHub, GitLab, Bitbucket, Azure DevOps, Cursor Origin, and AWS CodeCommit integration — deploy keys (and webhooks where the forge has them) configured automatically. HMAC signature verification. Or plug in any custom Git provider. If a token expires and keys/webhooks drift, cipi git refresh re-registers them on every app (--rotate-keys / --rotate-secret to mint new material).

📦 App Types

Laravel (default) — zero-downtime deploy with releases, shared storage, workers, scheduler, webhook; add --octane for FrankenPHP. --custom — for simple sites (e.g. WordPress, static+PHP): classic deploy into htdocs (no current/shared), choose docroot only (e.g. /, www, dist). Nginx: index index.html index.php, try_files $uri $uri/ /index.php?$args, error_page 404 /404.html. No DB, no .env, no cron, no workers, no webhook — just Nginx, PHP-FPM, and deploy key.

Clone an app for staging with cipi app clone <src> --domain=….

🌐 Aliases, www & SSL

Add multiple domains or subdomains to any app. A domain can be a wildcard (*.example.com), as the app's primary domain or as an alias — multi-tenant apps get one vhost for every tenant. Manage www/apex aliases and canonical redirects with cipi www. A single SAN certificate covers all of them — HTTP-01 by default, or DNS-01 via Cloudflare for wildcards. Auto-renew handles the rest.

Wildcard / multi-tenant in three commands (DNS of the zone on Cloudflare — proxied or not — and a * record pointing at the server):

cipi alias add myapp '*.myapp.com'               # routes every subdomain to the app (quote it)
cipi ssl dns set --token=<CLOUDFLARE_API_TOKEN>  # once per server: Zone → DNS → Edit
cipi ssl install myapp --dns=cloudflare          # one certificate: myapp.com + *.myapp.com

Cipi writes the HTTPS server block itself; certbot only issues and renews the certificate. Every subdomain is redirected from HTTP to HTTPS, renewals reload nginx, and a DNS-01 certificate is always reissued over DNS-01. Behind the Cloudflare proxy set SSL/TLS to Full (strict) — never Flexible, which loops on the HTTPS redirect. Without Cloudflare DNS, a wildcard certificate cannot be issued by Cipi: list the subdomains as aliases instead (HTTP-01). The full decision guide — with and without Cloudflare, Origin CA, tunnel — is in the docs. cipi ssl status shows, per app, what is served with which certificate and which names it does not cover.

Domains in different Cloudflare accounts. A wildcard certificate needs an API token of the Cloudflare account that holds the zone. One token is enough when every zone is in the same account; when your clients each have their own, give every account a name:

cipi ssl dns set --token=<TOKEN>                    # the default account
cipi ssl dns set --name=client-a --token=<TOKEN>    # another account, any number of them
cipi ssl dns list                                   # accounts and the certificates on each
cipi ssl install shop --dns=cloudflare --wildcard --account=client-a
cipi ssl dns remove client-a                        # refused while a certificate still renews with it

Each certificate renews with the token of the account it was issued with, and reissuing an app without --account keeps the account it had. cipi ssl dns set on an existing name replaces its token, which is how a token is rotated. Tokens are stored root-only and are never shown. cipi ssl status says which account every DNS-01 certificate uses.

↪️ Redirects & prefix proxies

Redirects and reverse proxies are written into the app's nginx vhost, so they cost nothing at runtime and never reach PHP. Every change runs nginx -t and is reverted if nginx refuses it.

cipi redirect set old-shop --to=https://shop.example.com   # whole app, path kept, 301
cipi redirect disable old-shop                             # serve it again, target kept
cipi redirect add shop /blog/ https://blog.example.com/ --308
cipi redirect add shop /promo /sale --302
cipi proxy add shop /api/ http://10.0.0.5:8080 --strip-prefix   # /api/users → :8080/users
cipi proxy add shop /events/ http://127.0.0.1:3000 --no-buffering

A proxy only takes its prefix; the rest of the app is served as usual. WebSockets pass through, X-Forwarded-* headers are set, and basic auth covers the prefix when it is on.

❤️ HTTP Healthchecks

Point Cipi at an HTTP endpoint; it probes every 5 minutes and alerts after consecutive failures:

cipi health set myapp --url=https://example.com/up --expect=200

📟 System Monitor & Chat Alerts

cipi health watches your apps; cipi monitor watches the server itself. Every 5 minutes it checks disk usage, SSL certificate expiry, system services (nginx, MariaDB, PHP-FPM, …), queue workers and Horizon, HTTP 5xx spikes in the access logs, read-only filesystems, and load average. Alerts fire on state changes only — one message when something breaks, one when it recovers, and a quiet reminder every 4 hours while it stays broken. No dashboards, no metrics storage: just a message when it matters.

cipi monitor                      # run all checks now
cipi monitor set disk --warn=80 --crit=90

The disk is watched from the first boot. Every filesystem of the server is checked every 5 minutes: a warning at 80% full, a critical alert at 90%, a recovery message when it drops back, and a reminder every 4 hours while it stays over. Those are the defaults, changed with the command above. Apps that have a soft disk limit (cipi app limits <app> --disk=<GB>) are watched the same way by the app_disk check; apps without one, the default, are not measured at all.

Alerts reach you where you actually look. Email works out of the box (cipi smtp configure); chat channels take one command and apply to every Cipi notification — deploys, backups, scans, logins, monitor alerts:

cipi notifications channel add slack ops --url=https://hooks.slack.com/services/…
cipi notifications channel add discord ops --url=https://discord.com/api/webhooks/…
cipi notifications channel add telegram ops --token=<bot-token> --chat-id=<id>
cipi notifications channel add ntfy phone --url=https://ntfy.sh/my-topic --priority=high

💽 Disk Usage, Server and Per App

cipi monitor tells you when the disk is filling up; cipi disk tells you who is filling it. One command shows every filesystem and then every app, largest first, in GB and as a percentage of the disk:

cipi disk            # the server, then every app
cipi disk db         # databases, in MB
cipi disk --json     # the same figures for scripts (also: cipi disk db --json)
  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%

Files is the app's home (releases, shared storage, logs). Database is the app's database: the one named after the app and, if shared/.env points at another one, that one too. Sizes below 0.01 GB are shown in MB, so a small database does not read as zero. Everything else is what remains of the used space: system, packages, logs, local backups, other databases. Sizes are measured when you run the command, so a server with large apps takes a few seconds.

Every database, engine by engine. cipi disk db goes through every engine installed on the server and prints sizes in MB:

  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 and PostgreSQL give one size per database. Valkey only knows how many keys each of its databases holds, and Meilisearch how many documents each index has (plus the size of an index's documents on recent versions): for those two the size in MB is the engine's, in memory and on disk. An engine that is not installed is left out.

The same figures over the API. With cipi/api 1.33+ on the server (Cipi 5.5.2+ for the sudoers entry), GET /api/disk and GET /api/disk/dbs return what cipi disk --json and cipi disk db --json print, read-only, behind the token ability disk-view — cipi-cli disk, the GUI's Server → Disk tab and the MCP tools DiskUsage / DiskDatabases use them.

A soft limit per app. No app has a limit until you give it one. A limit is a number of GB for files and database together (the TOTAL column), and it is a notification threshold: nothing is blocked and the app keeps working when it is passed.

cipi app limits shop --disk=10      # alert when shop reaches 10 GB
cipi app limits shop --disk=none    # back to no limit (the default)

cipi disk then shows the limit next to the app (10 GB (30%), and over once it is passed), and cipi monitor sends the alert: a warning at 90% of the limit, a critical one when the app is over it, a message when it is back under. Each app is alerted on its own. The limit is set on the server only; it cannot be changed from the app's cipi.yml.

⌨️ Tab-Completion That Is Already There

Press <Tab> after cipi and the shell completes commands, sub-commands, flags and app names. Nothing to enable: setup.sh installs it on new servers and every cipi self-update installs or refreshes it on existing ones.

sudo cipi dep<Tab>            # → deploy
sudo cipi deploy sh<Tab>      # → shop, shop2 (your apps)
sudo cipi firewall <Tab>      # → allow  list  attempts

It works the way servers are really used: after sudo cipi, which is how the cipi user runs it, with or without the bash-completion package; in the shells that sudo -s, su, tmux and screen open, which skip /etc/profile.d; and with app names for every user, not only root. All of that holds for bash; zsh loads the same completion from its vendor directory and /etc/profile.d. cipi completion bash|zsh is only for another user or a custom rc file.

📋 Compliance Evidence (ISO 27001 / SOC 2)

Cipi is self-hosted software, so it cannot be certified itself: ISO 27001 certifies an organisation, SOC 2 attests a service. What an agency or SaaS under audit needs is evidence that its servers meet the controls. cipi compliance report collects it in one read-only pass: SSH hardening, firewall, fail2ban, OS patches, kernel network parameters, TLS protocols and certificates, local accounts and sudo, SSH key fingerprints, API tokens (expiry, last use, scope), GUI 2FA, config encryption at rest, the deploy audit ledger (every deploy with commit, origin and operator, hash chain verified, cross-checked against Deployer's own release log), backup freshness, log forwarding, monitoring and alert delivery, NTP, and malware scanning. Each control is mapped to ISO/IEC 27001:2022 Annex A and SOC 2 criteria.

cipi compliance                   # pass / warn / fail per control, exit 1 on fail
cipi compliance report --days=90  # report.md + report.json + raw evidence + SHA256SUMS, as a .tar.gz

The bundle has no secrets (no token hashes, no key material, no vault content) and is written root-only under /var/log/cipi/compliance/. Give the archive and its SHA-256 to your auditor. Organisational controls such as policies, risk assessment and incident response are still yours to cover.

🤖 AI Agent Ready (MCP)

Cipi ships with a built-in MCP server. Laravel first: install the cipi-agent Laravel package, point your AI client at the endpoint, and deploy, rollback, query logs, and run Artisan commands via natural language — no SSH required.

Works with Claude, Cursor, VS Code, OpenAI, Gemini, and more.

composer require cipi/agent

MCP tools exposed: health, app_info, deploy, logs, db_query, artisan.

🔌 REST API (optional)

When you need to manage apps programmatically or integrate with external pipelines, enable the optional API layer with a single command. Bearer tokens, granular permissions, OpenAPI spec available, interactive Swagger docs.

🖥️ Web GUI (optional)

A self-hosted control panel for operators who prefer a browser over SSH. One login manages any number of Cipi servers through their REST API: register each server with a token, then switch between them from the header on any page.

Cipi control panel: dashboard with three connected servers
  • Apps — create Laravel (PHP-FPM or Octane), Node (SPA, static, SSR) and custom PHP apps; deploy, roll back and unlock; the deploy audit ledger and the deploy.php pipeline; domains, SSL, www ↔ apex, whole-app and path redirects, prefix proxies; .env with masked secrets, Composer auth.json, Artisan and whitelisted commands; basic auth, healthchecks, Meilisearch; logs with live refresh.
  • Databases — MariaDB and PostgreSQL: create, back up, restore, rotate passwords.
  • Server — overview, PHP versions, Node runtimes, database engines, services, healthchecks, monitor, email notifications, SSH keys, packages, Cloudflare Zero Trust and the API IP whitelist.
  • Jobs — long operations run on the server; the panel shows their output and lists one-time credentials (SSH and database passwords, deploy key, webhook token) with copy buttons.
  • Security — session login with TOTP 2FA, tokens encrypted at rest, nothing installed on the managed servers besides cipi api.

Install with cipi gui <domain> (then cipi gui ssl) — requires cipi api on each managed server. What is CLI-only by design (installing software, cipi ssh apps, firewall thresholds, dropping databases) stays on the CLI.

Docs and screenshots · GitHub

🔁 Sync Between Servers

Move entire stacks or single apps between Cipi servers — for migration, failover, or disaster recovery. Archives are encrypted in transit.

🖥️ CLI Client

A standalone Go binary that talks to the Cipi REST API from your local machine — no SSH required. Manage apps, databases, SSL, aliases, and deployments from any terminal. Pre-built binaries for Linux and macOS (amd64/arm64).

Docs · GitHub

🛒 WHMCS Module

An official provisioning module that bridges the WHMCS lifecycle to the Cipi REST API — automate app creation, deletion, SSL certificates, deployments, and package changes for your hosting customers. Self-contained drop-in, no Composer dependencies.

Docs · GitHub


Who uses Cipi?

  • Solo developers — ship Laravel first, without the DevOps overhead
  • Agencies — one VPS, many isolated client projects (Laravel first), onboard a new client in minutes
  • Startups & SaaS — atomic deploys, instant rollbacks, grow without changing your workflow
  • Datacenters & automation pipelines — every Cipi command is a plain shell call, wire it into Ansible or any provisioning script

Requirements

  • Ubuntu 24.04 LTS or 26.04 LTS (no other releases)
  • A VPS or VM with full virtualization (KVM, VMware, Hyper-V, Xen HVM) or a bare-metal server. Not shared-kernel virtualization (OpenVZ, LXC) and not container runtimes, including the desktop ones that emulate a Linux machine (Docker Desktop, Podman, OrbStack, WSL): Cipi sets up the firewall, fail2ban, swap and kernel parameters, which needs a kernel of its own. The installer stops there and says which environment it found.
  • Root access
  • Ports 22, 80, 443 open

Documentation

Full docs at: cipi.sh/docs


Contributing

Cipi is open source and MIT licensed. Issues, PRs, and feedback are welcome on GitHub.


Made with ❤️ by Andrea Pollastri

aws
cli
cloud
control-panel
deploy
deployment
devops
laravel
lemp
lemp-stack
linux
nginx
open-source
php
php-fpm
ubuntu
vps
vps-admin
vps-setup
whmcs

cipi-sh/cipi

Easy Laravel Deployments

Shell

1,145

999 commits

updated Oct 15, 2026

See the code

README

Cipi logo

Cipi

Easy Laravel Deployments
One command installs a complete production stack. One command deploys your app.
No panel, no bloat — so you can focus on what you love: building your application.

Website · Docs · Report a bug


What is Cipi?

Cipi turns any Ubuntu VPS into a multi-app PHP hosting platform — Laravel by default (PHP-FPM or optional Octane/FrankenPHP), with full isolation, zero-downtime deploys, SSL, queue workers, and S3 backups — all managed from a single CLI. Use --custom for simple sites: classic deploy (no releases/shared), configurable docroot and Nginx (try_files, entry point), no DB or cron.

No web panel. No bloat. No sleepless nights fighting Nginx configs or PHP-FPM pools.
Just SSH and the cipi command.

wget -O - https://cipi.sh/setup.sh | bash

Run this on the server, over SSH — not on your own machine — as a user with sudo.

Works on DigitalOcean, AWS EC2, Hetzner, Vultr, Linode, OVH, Google Cloud, Scaleway, and more.


From zero to production in 3 steps

1. Install Cipi on a fresh Ubuntu 24.04 or 26.04 VPS (~10 minutes). SSH into the server first and run it there, as a user with sudo:

wget -O - https://cipi.sh/setup.sh | bash

The installer asks for the public half of an SSH key (~/.ssh/id_ed25519.pub on your own machine; ssh-keygen -t ed25519 creates one if you have none — on Windows, from PowerShell or WSL). It ends by printing a root password: keep it in a password manager. Root is reachable only by logging in as the cipi user with that key and then running su root.

2. Create your app (Laravel by default, or cipi app create --custom for a simple deploy):

cipi app create
# username, domain, git repo, branch, PHP version
# → Laravel: user, DB, Nginx, workers, cron, webhook
# → Laravel Octane: cipi app create --octane
# → Custom: user, Nginx, PHP-FPM; Git optional (empty = SFTP-only to ~/htdocs)

3. Deploy and go live:

cipi deploy myapp
cipi ssl install myapp

That's it. Your Laravel app is live.


Stack

Every app gets a fully isolated environment. Laravel (default): zero-downtime deploy, DB, workers, cron, webhook — optionally Octane (FrankenPHP) instead of PHP-FPM. --custom: classic deploy into htdocs, configurable docroot only; Nginx uses index index.html index.php, try_files $uri $uri/ /index.php?$args, error_page 404 /404.html. No DB, no .env, no cron, no workers.

ComponentDetails
Web serverNginx reverse proxy with per-app virtual hosts — PHP-FPM or Octane (proxy_pass), optimized for Laravel
PHP & ComposerSelectable per app — PHP 7.4 to 8.5, hot-swappable
RuntimePHP-FPM pools by default; optional Laravel Octane (FrankenPHP) per app (--octane)
DatabaseMariaDB (default) + optional PostgreSQL; dedicated DB and user per Laravel app
SearchOptional Meilisearch for Laravel Scout — native binary, loopback only, per-app scoped key + index prefix
Queue workersSupervisor with per-app pools — queue:work or Horizon; optional Reverb for WebSockets (wss:// + credentials + fd limits)
DeploymentsDeployer — Laravel: atomic symlink, 5 releases, rollback, optional Node build; Custom: clone into htdocs
SSLLet's Encrypt via Certbot — HTTP-01 by default; optional DNS-01 (Cloudflare) + wildcards
SecurityFail2ban + UFW, optional CrowdSec (firewall bouncer), optional Cloudflare Zero Trust (cipi zt: tunnel, Access, origin dark), and nightly integrity/upload scan, per-app Linux user + PHP-FPM/Octane + SSH key
HealthchecksHTTP probes every 5 minutes, plus a post-deploy check with optional automatic rollback of a broken release
BackupsBackup profiles: what, how often, where, how long — S3/S3-compatible/local, client-side encryption
FrontendsNode apps: SPA and static sites served by nginx, SSR (Next, Nuxt, SvelteKit, Astro, Remix) with blue/green deploys, per-app Node version
Configurationcipi ini for php.ini; optional per-project cipi.yml for aliases, www, basic auth, redirects and proxies, search, databases, workers, Reverb, limits, deploy recipe, forced HTTPS, required .env names, scheduled commands, backups and post-deploy steps

Features

🔒 Security & Isolation by Design

Each app runs under its own Linux user with an isolated filesystem, PHP-FPM pool (or Octane process), and database. A compromise in one app cannot touch the others. Configs are encrypted at rest with AES-256 (Vault). GDPR-compliant log rotation included. Per-app resource limits (cipi app limits) cap FPM children, memory, Octane workers, and queue processes. cipi app fix-permissions restores that layout if a deploy or a zip-as-root left the home unreadable.

Brute-force protection is on from the first boot. UFW denies all inbound traffic except SSH, HTTP and HTTPS, and fail2ban bans the addresses that keep failing SSH logins. These are the defaults:

SettingDefault
Failed SSH logins before a ban3
Counted over1 hour
Ban24 hours, longer for each repeat, up to 7 days
Repeat offenders3 bans in 24 hours → banned for 7 days

The number of failed logins can be changed; the rest stays as above:

cipi firewall attempts            # current threshold, counting window and ban time
cipi firewall attempts 5          # ban after 5 failed SSH logins (1–100)
cipi firewall attempts default    # back to 3
cipi ban list                     # who is banned right now
cipi ban unban <IP>

The new value is checked with fail2ban-client -t and then applied with a fail2ban reload; a configuration fail2ban refuses is rolled back. It is stored in /etc/fail2ban/jail.d/cipi-attempts.local, so Cipi updates do not reset it.

App users can be kept out of SSH. Every app has its own Linux user, which can log in over SSH and SFTP with a password. Where nobody needs that, turn it off, for one app or for all of them:

cipi ssh apps                     # every app user: access enabled or disabled
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. Deploys are not affected: Deployer reaches the app user over ssh localhost, which stays allowed. The cipi user is never touched by these commands. By default every app user has access.

Optional, off until you turn them on — setup.sh / self-update never install them:

cipi crowdsec enable    # IP reputation → firewall bouncer (not a WAF)
                        # includes a TLS rescue URL: one GET allowlists your IP
cipi zt enable          # Cloudflare Zero Trust: tunnel + real_ip, ports stay open
                        # then hostname / Access / lock http / lock ssh as you choose
cipi scan enable        # nightly: release integrity + ClamAV on uploads

Ubuntu security updates land daily via unattended-upgrades. PHP patch releases are applied every Sunday by cipi php upgrade. Nginx, MariaDB, PostgreSQL and Valkey are left alone until you ask — restarting a database at 04:00 is not a surprise Cipi will create:

cipi nginx upgrade                 # nginx.org mainline, config test, reload
cipi db upgrade                    # MariaDB, and PostgreSQL if installed
cipi db upgrade pgsql --yes
cipi service upgrade valkey

Patch-level only (apt --only-upgrade of what is already installed). Cipi configs are kept. --yes skips the prompt.

⚡ Zero-Downtime Deploys

Deployer clones your repo, runs composer install, links storage, runs migrations, and swaps the symlink atomically. Optional Node build on deploy (cipi app edit --node-build=…). Roll back to any of the last 5 releases instantly. Opt-in pre-deploy DB snapshot (cipi deploy --snapshot).

Every deploy is recorded in a root-owned, hash-chained audit ledger (/var/log/cipi/deploys.jsonl, also sent to syslog), whatever started it: cipi deploy, the Git webhook, cipi/agent, the panel, or dep deploy run by hand. Each record has the release, the commit, and who started the deploy, read by root from the process chain: origin, login user and SSH address. cipi deploy <app> --audit shows it.

💾 Backups That Match How You Actually Work

Backups are driven by profiles: each one decides what it takes (application files, databases, or both), which apps and databases it covers, how often it runs, where it lands and how long it is kept. A 30-minute database-only copy kept on disk sits happily next to a nightly full copy encrypted to S3.

cipi backup profile add hourly-db --scope=db \
    --databases='shop,tenant_*' --exclude-tables='*.jobs,*.telescope_*' \
    --every=30m --keep=48 --dest=local

cipi backup profile add nightly --scope=all \
    --cron='0 2 * * *' --keep-days=14 --dest=s3 --encrypt

Databases are discovered from the engine, so tenant databases an app creates at runtime are backed up too. The schedule is written to root's crontab for you. cipi backup verify checks the newest run actually opens, and an overdue profile raises an alert — a backup that quietly stopped running is worse than none, because it still looks configured.

📄 cipi.yml — Configuration That Travels With the Code

An app can carry a cipi.yml in its repository describing the state it expects: domain aliases, the www redirect, HTTP basic auth, redirects and prefix proxies, Meilisearch for Scout, PHP version, settings and limits, its extra databases, its queue workers, its healthcheck, its deploy recipe options (keep_releases, the artisan hooks, predeploy_snapshot, and node_build — the asset build, which runs before the release goes live), the forced HTTPS redirect, the .env variables it requires (names only, never values), its scheduled commands (crons), its backup strategy, and post-deploy steps (deploy.post). cipi yml plan shows exactly what would change and cipi yml apply applies it.

Server reconciliation (aliases, PHP, workers, databases, …) is opt-in: cipi yml auto <app> on makes every successful deploy run cipi yml apply (from cipi deploy or the Git webhook). A release without the file is a no-op.

deploy.post is different — it runs after every successful deploy as soon as the section is in the committed file, with no auto switch, and only once the release is already live. It is not where the asset build goes. Steps use allowlisted runners only (artisan, npm, composer, php, node, …); no shell, no pipes. After Deployer, optional cipi yml apply, then deploy.post, then the post-deploy healthcheck.

deploy:
  node_build: "npm ci && npm run build"   # before the release goes live
  # post:
  #   - artisan cache:clear               # after the release is live
  # post_on_failure: abort   # default warn — log + email, release stays live
app:
  www: to-root                 # www → apex
  basic_auth:
    users: [admin]             # keeps the password set on the server
  limits:
    memory_limit: 512M         # cipi app limits, same bounds as the CLI
redirects:
  - from: /blog/
    to: "https://blog.example.com/"
proxies:
  - prefix: /api/
    upstream: "http://127.0.0.1:3000"
    strip_prefix: true
ssl:
  force_https: true            # needs a certificate; only ever turned on here
env:
  required: [STRIPE_KEY]       # names the .env must carry — plan blocks if missing
crons:
  - every: 30m                 # same allowlisted runners as deploy.post
    run: artisan queue:prune-batches
deploy:
  keep_releases: 3             # cipi app deploy-config, from the repository
  migrate: false
  predeploy_snapshot: true     # DB snapshot before each deploy
  node_build: "npm ci && npm run build"
cipi yml generate myapp --save       # writes /home/myapp/cipi.yml
cipi yml generate myapp > cipi.yml   # or print it
cipi yml example myapp > cipi.yml    # or a commented template
cipi yml plan myapp                  # server diff + "After deploy" steps
cipi yml post-deploy myapp           # run deploy.post now (test)

It can only configure an app that already exists, its databases must be named <app> or <app>_*, its backup profiles <app> or <app>-*, and its healthcheck URL one of the app's own domains — so a commit can never reach beyond its own app. Passwords never go in the file: a basic auth user listed by name keeps the password already on the server, and only bcrypt or SHA-512 crypt hashes are accepted. Proxies declared in the file cannot publish Cipi's own local services (databases, Valkey, another app's Octane/Reverb) or link-local and cloud metadata addresses, even through a hostname that resolves to one. crons entries go through the same allowlisted runners as deploy.post — never a free-form shell line — and only replace the crontab entries the file manages, tagged # cipi-yml. app.limits values outside the CLI's bounds block the plan instead of being clamped, and ssl.force_https can only ever be turned on from the file. A backup: section is the list of profiles this file owns: one it no longer names is removed from the schedule (the archives stay until cipi backup prune). A profile added with cipi backup profile add is not touched unless the file names it. deploy.snapshot is still read; the name to write is predeploy_snapshot.

⚛️ Node Frontends: SPA, Static and SSR

React, Vue, Svelte, Next.js, Nuxt, SvelteKit, Astro and Remix get the same flow as Laravel: git push, releases, rollback, webhooks, SSL, basic auth, redirects, audit ledger.

cipi app create --user=shop --domain=shop.com [email protected]:acme/shop.git --framework=next
cipi app create --user=docs --domain=docs.com [email protected]:acme/docs.git --node=static --output=dist
cipi app create --user=dash --domain=dash.com [email protected]:acme/dash.git --framework=vite
  • spa: built on deploy and served by nginx. Unknown paths fall back to index.html, fingerprinted assets are cached as immutable, HTML is revalidated.
  • static: pre-rendered output (Astro, nuxt generate, Next output: 'export') with real 404s.
  • ssr: a Node server on localhost behind nginx, with blue/green deploys. The new release starts on the idle slot and must answer on its health path before nginx switches to it; the old process stops only after that. A release that never comes up fails the deploy and the old one keeps serving. cipi node restart <app> uses the same switch.

Dependencies are installed exactly as locked (npm ci, pnpm, yarn or bun, detected from the lockfile), and the build runs with NODE_ENV=production. shared/.env is linked into every release, so it works for build-time variables (VITE_*, NEXT_PUBLIC_*) and runtime secrets alike. Presets (--framework=next|nuxt|sveltekit|astro|remix|vite) fill in the mode, build, start command and output folder; --build, --start, --output and --health-path override them.

Node per app, from nodejs.org. --node-version=22|24 installs the official build into /opt/cipi/node/<major>, verified against the release's SHA-256 list, with corepack for pnpm and yarn. Apps on different majors run side by side. cipi node upgrade --restart moves every app to the latest patch with no downtime.

Laravel apps too. cipi node default 24 makes that major the server-wide Node for Laravel asset builds (--node-build), cipi app run npm and cipi.yml post-deploy steps, overriding the system Node from NodeSource without uninstalling it (cipi node default system goes back). One app that needs something else keeps its own: cipi app edit legacy --node-version=22.

The same settings can live in the repository as a node: section of cipi.yml (framework, mode, version, build, start, output, health_path). With cipi yml auto <app> on, each deploy reads them from the commit being deployed, before the build, so a change ships together with the code that needs it.

Git webhooks work without cipi/agent: Cipi verifies GitHub/Bitbucket HMAC signatures and GitLab/Azure tokens itself, in a PHP pool that can do nothing but queue the app's own deploy. The start command runs as an argument list, never through a shell. .env is read as data, not sourced, and the server binds to 127.0.0.1 on a port Cipi assigns.

🚀 Laravel Octane (FrankenPHP)

Serve Laravel via Octane instead of PHP-FPM — same server, side by side with classic apps:

cipi app create --octane
cipi app convert myapp --to=octane   # or --to=fpm

Nginx proxies to a localhost Octane port; Supervisor runs ${app}-octane. Requires laravel/octane in the app repo (starts after the first successful deploy).

📡 Reverb, Horizon & Scheduler

First-class Laravel extras, all CLI-managed:

cipi app reverb enable myapp
cipi worker horizon enable myapp
cipi schedule on myapp

Enabling Reverb allocates a localhost port, writes a Supervisor program, and proxies the two paths the Pusher protocol uses — /app/{key} for the WebSocket and /apps/{id}/… for the broadcast API — through Nginx on the app's own domain. It also generates REVERB_APP_ID/KEY/SECRET and the VITE_REVERB_* copies the frontend build reads, picks ws:// or wss:// from whether the app has a certificate (and moves to wss:// by itself when one is installed later), and raises the file-descriptor limits that otherwise cap a WebSocket server at about a thousand concurrent clients.

An app with its own /app or /apps route cannot use Reverb behind the same domain — those paths belong to the protocol. Reverb can also be declared in cipi.yml as workers.reverb: true.

Horizon is mutually exclusive with queue:work workers. Scheduler toggles the crontab schedule:run entry.

🔎 Meilisearch for Laravel Scout (optional)

A self-hosted alternative to Algolia, off by default and never installed by setup.sh or cipi self-update:

cipi search install          # binary + systemd unit on 127.0.0.1:7700
cipi search enable myapp     # scoped key + Scout settings in the app .env

Meilisearch is a single static Rust binary, so it installs natively like the rest of the stack — no Docker in the loop. enable writes SCOUT_DRIVER, MEILISEARCH_HOST, MEILISEARCH_KEY and SCOUT_PREFIX into shared/.env; then composer require laravel/scout meilisearch/meilisearch-php and php artisan scout:import "App\Models\Post" in the app.

One instance, real isolation. Meilisearch has no per-tenant databases, so Cipi gives every app an API key scoped to the index pattern <app>-* — with only the actions Scout calls, never key management — and writes SCOUT_PREFIX itself rather than leaving it to you. Cipi usernames contain no hyphens, so blog- can never overlap blogs-: an app that ignores the prefix gets a 403 rather than its neighbour's documents. The master key stays in the vault and in a root-only EnvironmentFile, never in an app .env and never on a command line.

cipi search key rotate <app> reissues one key; --master rotates the master key and rewrites every app's .env in the same run (Meilisearch derives each key from the master, so they all change at once). cipi search upgrade swaps the binary and does the in-place store upgrade, rolling the binary back if the engine refuses — and only dropping the indexes if you say so, since scout:import rebuilds them. Indexes are derived data, so they are deliberately not part of cipi backup.

For a small dataset you may not need any of this: Scout's database driver, or Postgres full-text, needs no extra service.

📦 Optional host tools

Some projects need a binary on the host — an image optimiser, ffmpeg, pdftotext. cipi package installs them from Ubuntu's own repositories, from a closed allowlist:

cipi package list
cipi package install image-optimizers   # or ffmpeg, imagemagick, poppler-utils
cipi package remove ffmpeg

The allowlist is the feature: without it this would be a root apt shell with extra steps. An entry has to be a stateless binary from an Ubuntu repo — no daemon, no port, no credentials, no state that outlives the process — with a real Laravel package behind it. Anything else is a service, and goes through cipi search, cipi db install or the container branch. Chromium is the instructive rejection: on Ubuntu 24.04 chromium isn't a deb at all and chromium-browser is a transitional package that depends on snapd, so "just apt-install it" would quietly add a self-updating daemon.

install shows what apt actually intends to pull — package count and disk — and asks before running. remove purges only what is present, then shows which orphaned dependencies autoremove would take before touching them.

Already in the base stack, so not in the list: the Imagick PHP extension, Ghostscript and fonts-dejavu-core (Recommends of php-imagick), and Node 22 (cipi node default changes it).

🔗 Webhook Auto-Deploy

Native GitHub, GitLab, Bitbucket, Azure DevOps, Cursor Origin, and AWS CodeCommit integration — deploy keys (and webhooks where the forge has them) configured automatically. HMAC signature verification. Or plug in any custom Git provider. If a token expires and keys/webhooks drift, cipi git refresh re-registers them on every app (--rotate-keys / --rotate-secret to mint new material).

📦 App Types

Laravel (default) — zero-downtime deploy with releases, shared storage, workers, scheduler, webhook; add --octane for FrankenPHP. --custom — for simple sites (e.g. WordPress, static+PHP): classic deploy into htdocs (no current/shared), choose docroot only (e.g. /, www, dist). Nginx: index index.html index.php, try_files $uri $uri/ /index.php?$args, error_page 404 /404.html. No DB, no .env, no cron, no workers, no webhook — just Nginx, PHP-FPM, and deploy key.

Clone an app for staging with cipi app clone <src> --domain=….

🌐 Aliases, www & SSL

Add multiple domains or subdomains to any app. A domain can be a wildcard (*.example.com), as the app's primary domain or as an alias — multi-tenant apps get one vhost for every tenant. Manage www/apex aliases and canonical redirects with cipi www. A single SAN certificate covers all of them — HTTP-01 by default, or DNS-01 via Cloudflare for wildcards. Auto-renew handles the rest.

Wildcard / multi-tenant in three commands (DNS of the zone on Cloudflare — proxied or not — and a * record pointing at the server):

cipi alias add myapp '*.myapp.com'               # routes every subdomain to the app (quote it)
cipi ssl dns set --token=<CLOUDFLARE_API_TOKEN>  # once per server: Zone → DNS → Edit
cipi ssl install myapp --dns=cloudflare          # one certificate: myapp.com + *.myapp.com

Cipi writes the HTTPS server block itself; certbot only issues and renews the certificate. Every subdomain is redirected from HTTP to HTTPS, renewals reload nginx, and a DNS-01 certificate is always reissued over DNS-01. Behind the Cloudflare proxy set SSL/TLS to Full (strict) — never Flexible, which loops on the HTTPS redirect. Without Cloudflare DNS, a wildcard certificate cannot be issued by Cipi: list the subdomains as aliases instead (HTTP-01). The full decision guide — with and without Cloudflare, Origin CA, tunnel — is in the docs. cipi ssl status shows, per app, what is served with which certificate and which names it does not cover.

Domains in different Cloudflare accounts. A wildcard certificate needs an API token of the Cloudflare account that holds the zone. One token is enough when every zone is in the same account; when your clients each have their own, give every account a name:

cipi ssl dns set --token=<TOKEN>                    # the default account
cipi ssl dns set --name=client-a --token=<TOKEN>    # another account, any number of them
cipi ssl dns list                                   # accounts and the certificates on each
cipi ssl install shop --dns=cloudflare --wildcard --account=client-a
cipi ssl dns remove client-a                        # refused while a certificate still renews with it

Each certificate renews with the token of the account it was issued with, and reissuing an app without --account keeps the account it had. cipi ssl dns set on an existing name replaces its token, which is how a token is rotated. Tokens are stored root-only and are never shown. cipi ssl status says which account every DNS-01 certificate uses.

↪️ Redirects & prefix proxies

Redirects and reverse proxies are written into the app's nginx vhost, so they cost nothing at runtime and never reach PHP. Every change runs nginx -t and is reverted if nginx refuses it.

cipi redirect set old-shop --to=https://shop.example.com   # whole app, path kept, 301
cipi redirect disable old-shop                             # serve it again, target kept
cipi redirect add shop /blog/ https://blog.example.com/ --308
cipi redirect add shop /promo /sale --302
cipi proxy add shop /api/ http://10.0.0.5:8080 --strip-prefix   # /api/users → :8080/users
cipi proxy add shop /events/ http://127.0.0.1:3000 --no-buffering

A proxy only takes its prefix; the rest of the app is served as usual. WebSockets pass through, X-Forwarded-* headers are set, and basic auth covers the prefix when it is on.

❤️ HTTP Healthchecks

Point Cipi at an HTTP endpoint; it probes every 5 minutes and alerts after consecutive failures:

cipi health set myapp --url=https://example.com/up --expect=200

📟 System Monitor & Chat Alerts

cipi health watches your apps; cipi monitor watches the server itself. Every 5 minutes it checks disk usage, SSL certificate expiry, system services (nginx, MariaDB, PHP-FPM, …), queue workers and Horizon, HTTP 5xx spikes in the access logs, read-only filesystems, and load average. Alerts fire on state changes only — one message when something breaks, one when it recovers, and a quiet reminder every 4 hours while it stays broken. No dashboards, no metrics storage: just a message when it matters.

cipi monitor                      # run all checks now
cipi monitor set disk --warn=80 --crit=90

The disk is watched from the first boot. Every filesystem of the server is checked every 5 minutes: a warning at 80% full, a critical alert at 90%, a recovery message when it drops back, and a reminder every 4 hours while it stays over. Those are the defaults, changed with the command above. Apps that have a soft disk limit (cipi app limits <app> --disk=<GB>) are watched the same way by the app_disk check; apps without one, the default, are not measured at all.

Alerts reach you where you actually look. Email works out of the box (cipi smtp configure); chat channels take one command and apply to every Cipi notification — deploys, backups, scans, logins, monitor alerts:

cipi notifications channel add slack ops --url=https://hooks.slack.com/services/…
cipi notifications channel add discord ops --url=https://discord.com/api/webhooks/…
cipi notifications channel add telegram ops --token=<bot-token> --chat-id=<id>
cipi notifications channel add ntfy phone --url=https://ntfy.sh/my-topic --priority=high

💽 Disk Usage, Server and Per App

cipi monitor tells you when the disk is filling up; cipi disk tells you who is filling it. One command shows every filesystem and then every app, largest first, in GB and as a percentage of the disk:

cipi disk            # the server, then every app
cipi disk db         # databases, in MB
cipi disk --json     # the same figures for scripts (also: cipi disk db --json)
  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%

Files is the app's home (releases, shared storage, logs). Database is the app's database: the one named after the app and, if shared/.env points at another one, that one too. Sizes below 0.01 GB are shown in MB, so a small database does not read as zero. Everything else is what remains of the used space: system, packages, logs, local backups, other databases. Sizes are measured when you run the command, so a server with large apps takes a few seconds.

Every database, engine by engine. cipi disk db goes through every engine installed on the server and prints sizes in MB:

  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 and PostgreSQL give one size per database. Valkey only knows how many keys each of its databases holds, and Meilisearch how many documents each index has (plus the size of an index's documents on recent versions): for those two the size in MB is the engine's, in memory and on disk. An engine that is not installed is left out.

The same figures over the API. With cipi/api 1.33+ on the server (Cipi 5.5.2+ for the sudoers entry), GET /api/disk and GET /api/disk/dbs return what cipi disk --json and cipi disk db --json print, read-only, behind the token ability disk-view — cipi-cli disk, the GUI's Server → Disk tab and the MCP tools DiskUsage / DiskDatabases use them.

A soft limit per app. No app has a limit until you give it one. A limit is a number of GB for files and database together (the TOTAL column), and it is a notification threshold: nothing is blocked and the app keeps working when it is passed.

cipi app limits shop --disk=10      # alert when shop reaches 10 GB
cipi app limits shop --disk=none    # back to no limit (the default)

cipi disk then shows the limit next to the app (10 GB (30%), and over once it is passed), and cipi monitor sends the alert: a warning at 90% of the limit, a critical one when the app is over it, a message when it is back under. Each app is alerted on its own. The limit is set on the server only; it cannot be changed from the app's cipi.yml.

⌨️ Tab-Completion That Is Already There

Press <Tab> after cipi and the shell completes commands, sub-commands, flags and app names. Nothing to enable: setup.sh installs it on new servers and every cipi self-update installs or refreshes it on existing ones.

sudo cipi dep<Tab>            # → deploy
sudo cipi deploy sh<Tab>      # → shop, shop2 (your apps)
sudo cipi firewall <Tab>      # → allow  list  attempts

It works the way servers are really used: after sudo cipi, which is how the cipi user runs it, with or without the bash-completion package; in the shells that sudo -s, su, tmux and screen open, which skip /etc/profile.d; and with app names for every user, not only root. All of that holds for bash; zsh loads the same completion from its vendor directory and /etc/profile.d. cipi completion bash|zsh is only for another user or a custom rc file.

📋 Compliance Evidence (ISO 27001 / SOC 2)

Cipi is self-hosted software, so it cannot be certified itself: ISO 27001 certifies an organisation, SOC 2 attests a service. What an agency or SaaS under audit needs is evidence that its servers meet the controls. cipi compliance report collects it in one read-only pass: SSH hardening, firewall, fail2ban, OS patches, kernel network parameters, TLS protocols and certificates, local accounts and sudo, SSH key fingerprints, API tokens (expiry, last use, scope), GUI 2FA, config encryption at rest, the deploy audit ledger (every deploy with commit, origin and operator, hash chain verified, cross-checked against Deployer's own release log), backup freshness, log forwarding, monitoring and alert delivery, NTP, and malware scanning. Each control is mapped to ISO/IEC 27001:2022 Annex A and SOC 2 criteria.

cipi compliance                   # pass / warn / fail per control, exit 1 on fail
cipi compliance report --days=90  # report.md + report.json + raw evidence + SHA256SUMS, as a .tar.gz

The bundle has no secrets (no token hashes, no key material, no vault content) and is written root-only under /var/log/cipi/compliance/. Give the archive and its SHA-256 to your auditor. Organisational controls such as policies, risk assessment and incident response are still yours to cover.

🤖 AI Agent Ready (MCP)

Cipi ships with a built-in MCP server. Laravel first: install the cipi-agent Laravel package, point your AI client at the endpoint, and deploy, rollback, query logs, and run Artisan commands via natural language — no SSH required.

Works with Claude, Cursor, VS Code, OpenAI, Gemini, and more.

composer require cipi/agent

MCP tools exposed: health, app_info, deploy, logs, db_query, artisan.

🔌 REST API (optional)

When you need to manage apps programmatically or integrate with external pipelines, enable the optional API layer with a single command. Bearer tokens, granular permissions, OpenAPI spec available, interactive Swagger docs.

🖥️ Web GUI (optional)

A self-hosted control panel for operators who prefer a browser over SSH. One login manages any number of Cipi servers through their REST API: register each server with a token, then switch between them from the header on any page.

Cipi control panel: dashboard with three connected servers
  • Apps — create Laravel (PHP-FPM or Octane), Node (SPA, static, SSR) and custom PHP apps; deploy, roll back and unlock; the deploy audit ledger and the deploy.php pipeline; domains, SSL, www ↔ apex, whole-app and path redirects, prefix proxies; .env with masked secrets, Composer auth.json, Artisan and whitelisted commands; basic auth, healthchecks, Meilisearch; logs with live refresh.
  • Databases — MariaDB and PostgreSQL: create, back up, restore, rotate passwords.
  • Server — overview, PHP versions, Node runtimes, database engines, services, healthchecks, monitor, email notifications, SSH keys, packages, Cloudflare Zero Trust and the API IP whitelist.
  • Jobs — long operations run on the server; the panel shows their output and lists one-time credentials (SSH and database passwords, deploy key, webhook token) with copy buttons.
  • Security — session login with TOTP 2FA, tokens encrypted at rest, nothing installed on the managed servers besides cipi api.

Install with cipi gui <domain> (then cipi gui ssl) — requires cipi api on each managed server. What is CLI-only by design (installing software, cipi ssh apps, firewall thresholds, dropping databases) stays on the CLI.

Docs and screenshots · GitHub

🔁 Sync Between Servers

Move entire stacks or single apps between Cipi servers — for migration, failover, or disaster recovery. Archives are encrypted in transit.

🖥️ CLI Client

A standalone Go binary that talks to the Cipi REST API from your local machine — no SSH required. Manage apps, databases, SSL, aliases, and deployments from any terminal. Pre-built binaries for Linux and macOS (amd64/arm64).

Docs · GitHub

🛒 WHMCS Module

An official provisioning module that bridges the WHMCS lifecycle to the Cipi REST API — automate app creation, deletion, SSL certificates, deployments, and package changes for your hosting customers. Self-contained drop-in, no Composer dependencies.

Docs · GitHub


Who uses Cipi?

  • Solo developers — ship Laravel first, without the DevOps overhead
  • Agencies — one VPS, many isolated client projects (Laravel first), onboard a new client in minutes
  • Startups & SaaS — atomic deploys, instant rollbacks, grow without changing your workflow
  • Datacenters & automation pipelines — every Cipi command is a plain shell call, wire it into Ansible or any provisioning script

Requirements

  • Ubuntu 24.04 LTS or 26.04 LTS (no other releases)
  • A VPS or VM with full virtualization (KVM, VMware, Hyper-V, Xen HVM) or a bare-metal server. Not shared-kernel virtualization (OpenVZ, LXC) and not container runtimes, including the desktop ones that emulate a Linux machine (Docker Desktop, Podman, OrbStack, WSL): Cipi sets up the firewall, fail2ban, swap and kernel parameters, which needs a kernel of its own. The installer stops there and says which environment it found.
  • Root access
  • Ports 22, 80, 443 open

Documentation

Full docs at: cipi.sh/docs


Contributing

Cipi is open source and MIT licensed. Issues, PRs, and feedback are welcome on GitHub.


Made with ❤️ by Andrea Pollastri

aws
cli
cloud
control-panel
deploy
deployment
devops
laravel
lemp
lemp-stack
linux
nginx
open-source
php
php-fpm
ubuntu
vps
vps-admin
vps-setup
whmcs

Significant stargazers

Stefan Zweifel

586 followers · starred Jun 2026

Ishan Vyas

93 followers · starred Apr 2023

Shalvah

747 followers · starred Nov 2019

Ante Primorac

31 followers · starred Oct 2020