cipi deploy

Cipi uses Deployer for all deployments. Every deploy is atomic: a new release directory is prepared fully before the current symlink is swapped, so traffic is never interrupted.

Since v4.5.4 Cipi bundles Deployer 8, which requires PHP ≥ 8.3. cipi deploy and cipi deploy --rollback abort with a clear upgrade message before invoking Deployer when an app is still pinned to an older PHP version — switch it with cipi app edit <app> --php=8.3 (or higher) first.

Deploy pipeline

Deployer and Composer run with the app's configured PHP version (e.g. /usr/bin/php8.5), not the system default. This applies to cipi deploy, cipi deploy --rollback, crontab deploy triggers, cipi sync import deploys, and the deploy / composer aliases in the app user's .bashrc.

  1. Stop queue workers (cipi worker stop)
  2. Clone repo into releases/N/
  3. Run composer install --no-dev (with app's PHP)
  4. Link shared/.env and shared/storage/
  5. Run artisan migrate --force
  6. Run artisan optimize
  7. Run artisan storage:link
  8. Swap current symlink atomically
  9. Restart queue workers
  10. Prune old releases (keep last 5)
bash
$ cipi deploy myapp              # deploy latest commit
$ cipi deploy myapp --rollback   # instant rollback to previous release
$ cipi deploy myapp --releases   # list releases with date, commit and subject (v5.1.0+)
$ cipi deploy myapp --log        # timestamped deploy log (v5.1.0+)
$ cipi deploy myapp --log=200    # last 200 lines of it
$ cipi deploy myapp --audit      # v5.4.0+ who and what deployed each release [--days=90] [--json]
$ cipi deploy myapp --key        # show the SSH deploy key
$ cipi deploy myapp --webhook    # show webhook URL and token
$ cipi deploy myapp --unlock     # remove a stuck deploy lock
$ cipi deploy myapp --snapshot   # v5.0+ opt-in DB dump before deploy
$ cipi deploy myapp --snapshot-required  # fail if snapshot cannot be taken
$ cipi deploy myapp --rollback-on-unhealthy  # v5.1.0+ undo a release that fails its healthcheck
$ cipi deploy myapp --trust-host=git.mycompany.com       # trust a custom Git server fingerprint
$ cipi deploy myapp --trust-host=git.mycompany.com:2222  # trust on non-standard port (also writes ~/.ssh/config)

Knowing whether a deploy worked (v5.1.0+)

Up to 5.0.x a failing deploy could pass in silence: the failure branch, its warnings, the rollback hint and the deploy_fail email were unreachable code, because a non-zero exit from Deployer killed the cipi process on the spot. Since v5.1.0:

  • Both paths email on success and on failure — the CLI and the automatic Git webhook alike. Subjects are visibly different (Cipi deploy succeeded: myapp release 55 … / Cipi deploy FAILED: myapp …), and the body names the branch, the release number, the commit hash and subject, its author and date, the duration, the previous release, the deploy log path and the post-deploy healthcheck verdict.
  • cipi deploy exits non-zero when the deploy failed, so CI and webhooks can see it. The same applies to cipi deploy --rollback.
  • The success email is sent after post-deploy verification, so it can never announce a successful deploy while the site is returning 500.

Reading the deploy log

/home/<app>/logs/deploy.log used to be raw Deployer output appended forever, which made a deploy that failed overnight unreadable afterwards. Since v5.1.0 every line is timestamped and each run is bracketed by a banner naming the trigger (CLI or webhook), the branch, the release and the duration.

bash
$ cipi deploy myapp --log=100   # same as tailing /home/myapp/logs/deploy.log
$ cipi deploy myapp --releases  # release number, date, commit, subject

Release directories stay numeric — rollback depends on that ordering — so --releases adds the human detail on top rather than renaming anything.

Since v5.0, opt-in pre-deploy DB snapshots are available via --snapshot / --snapshot-required or a permanent app setting — see Pre-deploy DB snapshots. Octane apps use the laravel-octane.php Deployer template (reload/restart Octane on deploy); enable Node builds with cipi app edit <app> --node-build='…'.

If a deploy is interrupted (e.g. by a network error), Deployer may leave a lock file behind. Use cipi deploy myapp --unlock to remove it before re-deploying.

Deploy audit ledger

Available since v5.4.0. Before, only cipi deploy and the webhook wrapper wrote deploy banners — into a log the app user owns. A deploy started any other way left no record: cipi/agent running dep inside the app, the panel, or dep deploy typed over SSH. What all of them share is the app's Deployer recipe, so the record is written from there.

bash
$ cipi deploy myapp --audit               # last 90 days
$ cipi deploy myapp --audit --days=365 --json
$ cipi compliance deploys                 # chain check + gaps, as audit evidence

How a record is written

The recipe calls /usr/local/bin/cipi-deploy-audit (root, through one per-app sudoers rule) after deploy:symlink (published), on deploy:failed (failed) and after rollback; custom apps after deploy and on its failure. The hook runs locally, so the process chain of whoever ran dep stays visible. A missing helper or rule never fails a deploy.

Root does not take the caller's word for anything. From /proc it reads the release current points at, its commit (Deployer's REVISION, or htdocs HEAD for custom apps) and Deployer's own releases_log entry. From the process chain it reads:

Field Values
origin cipi-cli (trigger cli, rollback, auto-rollback, sync), panel (cipi deploy started by www-data), webhook, app-web (PHP-FPM), app-queue, ssh, cron, root-shell
operator The audit login uid, set by PAM and not changeable by the user
ip / chain The SSH client IP, and the process chain itself
claimed What only the app can say — e.g. who pressed deploy in an MCP client — kept apart from what root verified

Claims come from the trigger file: the cron now moves ~/.deploy-trigger to ~/.deploy-trigger.run instead of deleting it, and the file may carry source, actor, ip, ref and request_id as JSON or KEY=VALUE. Code that runs dep in-process can set the same CIPI_DEPLOY_* environment variables. A known source also names the trigger in deploy.log (trigger=mcp).

The ledger

/var/log/cipi/deploys.jsonl is root-only, one JSON line per event. Each line carries its sequence number and the SHA-256 of the line before it, and is also sent to syslog (cipi-deploy), so log forwarding keeps a copy off the server. Repeated calls for a release already recorded are ignored.

cipi compliance deploys uses the ledger: it fails when the hash chain is broken, and warns when an app's deploy.php lacks the hook (edited by hand), when its sudoers lacks the rule, or when Deployer's releases_log has a release the ledger never saw. The panel API reads the same records at GET /api/apps/{name}/deploy/audit (API 1.31+), and the GUI shows them on the app page.

Honest about gaps. The chain proves that nothing before the newest record was changed or removed. Root can still rewrite the newest records or the whole file — the syslog copy forwarded off the server is the independent one. The ledger records deploys, not every file change: an app user can still edit current/ over SFTP (covered by cipi scan integrity) or remove the hook from their own deploy.php (flagged by compliance and cross-checked against releases_log). Existing apps get the hooks from migration 5.4.0; only releases since auditing began count.

Pre-deploy DB snapshots

Since v5.0, cipi deploy <app> can dump the database before the release pipeline runs. Snapshots land under /var/log/cipi/backups/ — the same path used by cipi db backup.

bash
$ cipi deploy shop --snapshot            # dump first; warn and continue on failure
$ cipi deploy shop --snapshot-required   # dump first; abort deploy if snapshot fails

What happens

  1. Before Deployer starts, Cipi takes a DB dump into /var/log/cipi/backups/.
  2. With --snapshot-required, a failed dump (or a missing DB engine) blocks the deploy.
  3. With --snapshot alone, a failed dump prints a warning and the deploy continues.
--snapshotOpt-in dump before deploy; warn and continue if the snapshot cannot be taken.
--snapshot-requiredSame dump, but fail the deploy when the snapshot (or engine) is unavailable.

Enable on every deploy

Turn it on permanently for an app so every deploy (CLI, webhook, or pipeline) takes a snapshot first:

bash
$ cipi app edit shop --predeploy-snapshot
cipi deploy --rollback restores the previous code release only. It does not restore the database. If you need the pre-deploy dump back, use cipi db restore.

For pipeline workflows that also archive shared/ to S3 before release, see Safe deploy — backup before release.

cipi app deploy-config

Since v5.0.3, manage durable Deployer recipe options stored in apps.json and applied by regenerating deploy.php from the template — a safe alternative to editing free-form PHP.

bash
$ cipi app deploy-config myapp
$ cipi app deploy-config myapp --keep-releases=5
$ cipi app deploy-config myapp --migrate --optimize --storage-link
$ cipi app deploy-config myapp --no-migrate --no-optimize
$ cipi app deploy-config myapp --queue-restart --horizon-terminate
$ cipi app deploy-config myapp --extra-artisan=view:clear,event:cache
$ cipi app deploy-config myapp --node-build='npm ci && npm run build'
$ cipi app deploy-config myapp --predeploy-snapshot

REST: GET|PUT /api/apps/{name}/deploy-config (ability apps-deploy-config, API 1.14+ / Cipi 5.0.3+). MCP: AppDeployConfigShow, AppDeployConfigUpdate.

cipi.yml — configuration that travels with the code

Available since v5.1.0. An app can carry a cipi.yml file in its repository describing the state it expects: domain aliases, PHP version and settings, extra databases, queue workers or Horizon, Reverb, the scheduler, its healthcheck and its backup strategy. Since v5.2.3 it can also declare post-deploy steps, and since v5.4.0 the www redirect, basic auth, redirects and proxies, Meilisearch, recipe options, limits, forced HTTPS, required .env names and crons, and a Node app's build. The file lives next to the code, so server configuration is reviewed, versioned and shipped like everything else.

Commands

bash
$ cipi yml generate myapp          # this app's current config, as a cipi.yml
$ cipi yml example myapp           # blank commented template, in myapp's namespace
$ cipi yml validate myapp          # parse and check, change nothing
$ cipi yml plan myapp              # show exactly what would change
$ cipi yml apply myapp [--yes]     # apply it
$ cipi yml auto myapp on|off|status  # apply after every successful deploy
$ cipi yml post-deploy myapp       # run the declared deploy.post steps now (5.2.3+)

The file is looked up in current/cipi.yml, then current/cipi.yaml, then shared/cipi.yml — and, since v5.2.3, in htdocs/, which is where a custom app's repository actually lives. Override with --file=<path>.

Fixed in v5.2.3: on a custom app, htdocs/ is the document root, so a committed cipi.yml was downloadable at https://<domain>/cipi.yml. Every app vhost now denies /cipi.yml and /cipi.yaml at any path, Laravel included. Existing servers get the rule on update, without regenerating the vhost. Update, then check your own domains.

Start from what the server already has

You do not have to write it by hand. cipi yml generate <app> prints the app's configuration as it stands on the server — aliases, PHP version and per-app settings, its extra databases, its queue workers (read back out of Supervisor), Horizon, Reverb (since v5.1.2), the scheduler and the backup profiles it owns — as a ready-to-commit file. Declaring horizon: false or reverb: false is now a real plan (also 5.1.2): previously false was treated like a missing key.

bash
$ cipi yml generate myapp > cipi.yml   # then commit it
$ cipi yml plan myapp                  # reports nothing to do

Cron expressions come back as the friendlier every: 30m form where they map cleanly, values are quoted wherever a plain scalar would be misread, and the result is fed through the validator before printing. Server-wide backup profiles and the app's own database are deliberately left out — those stay yours.

The file

yaml
version: 1

app:
  # 8.3, 8.4 or 8.5 — must already be installed (cipi php install 8.5)
  php: "8.5"

  # The declared list replaces the current aliases: one you remove here is
  # removed from the server. The primary domain is not managed here.
  aliases:
    - "www.myapp.com"
    - "*.myapp.com"      # wildcard, for multi-tenant subdomains

  # Per-app php.ini overrides. Server-wide values stay with `cipi ini set`.
  ini:
    upload_max_filesize: 50M
    post_max_size: 60M
    memory_limit: 512M

# Extra databases beyond the one created with the app. Credentials land in
# /home/myapp/shared/cipi-databases.env — never written back to the repo.
databases:
  - name: myapp_reporting
  - name: myapp_analytics
    engine: pgsql          # mariadb (default) or pgsql

workers:
  horizon: false           # true replaces the queue workers below

  # Laravel Reverb (5.1.2+). Cipi allocates a localhost port, adds the Supervisor
  # program, proxies /app/{key} and /apps/{id}/… on this app's own domain, and
  # generates REVERB_APP_ID/KEY/SECRET plus the VITE_ copies in the .env. Laravel
  # apps only — declaring it on a --custom app is refused.
  reverb: false

  queues:
    - queue: default
      processes: 2
    - queue: emails
      processes: 1
      tries: 5
      timeout: 300

# Laravel scheduler (* * * * * artisan schedule:run)
schedule: true

# HTTP healthcheck. Probed every 5 minutes and right after every deploy.
# The URL must be one of this app's own domains.
# To remove an existing healthcheck, write only  health: { enabled: false }  (honoured since 5.3.0).
health:
  url: "https://myapp.com/up"
  expect: 200
  # grace: 8                      # seconds before the first probe after a deploy
  # postdeploy: false             # skip the check right after a deploy
  # rollback_on_unhealthy: true   # undo a release that fails the check
  #                               # (the code symlink only — migrations are NOT undone)

# Post-deploy steps (5.2.3+). Allowlisted runners only — no free shell.
# Unlike every other section, this one does NOT need `cipi yml auto` on.
deploy:
  post:
    - artisan cache:clear
    - artisan scout:import --force
    - npm run build
    - composer dump-autoload -o
  # post_on_failure: abort   # default warn

# Backup strategy for this app. Profile names must be myapp or myapp-*.
backup:
  profiles:
    # Frequent and cheap: databases only, without the noisy tables.
    - name: myapp-db
      scope: db
      databases: ["myapp", "myapp_*", "tenant_*"]
      exclude_tables: ["*.jobs", "*.telescope_*"]
      every: 30m           # 5m/10m/15m/20m/30m, 1h..12h, 1d..28d
      keep: 48             # keep the last 48 runs
      destinations: [local]

    # Slower, complete, off-site and encrypted.
    - name: myapp-nightly
      scope: all           # all | files | db
      cron: "0 2 * * *"
      keep_days: 14
      destinations: [s3]
      encrypt: true
cipi yml example takes an optional app name — cipi yml example myapp — so the placeholder databases and profiles land inside that app's namespace and the template validates as-is.

www, basic auth, redirects and proxies (v5.4.0)

yaml
app:
  aliases: [ "www.myapp.com" ]
  www: to-root              # to-root | from-root | none
  basic_auth:
    users:
      - admin               # keeps the password already set on the server
      - name: preview
        password_hash: "$2y$12$…"   # bcrypt (cost 10+) or SHA-512 crypt only

redirect:                   # whole app, one hop; "redirect: {enabled: false}" removes it
  to: "https://new.example.com"
  code: 301
  keep_path: true

redirects:                  # the declared list replaces the current one
  - from: /old-page
    to: /new-page
  - from: /blog/            # trailing / = prefix
    to: "https://blog.myapp.com/"
    code: 308

proxies:
  - prefix: /api/
    upstream: "http://127.0.0.1:3000"
    strip_prefix: true
    # preserve_host: true   timeout: 60   buffering: false
  • app.www: when both are declared, the other name of the pair must be in app.aliases. An alias list that drops a name the current redirect needs is refused at plan time instead of failing halfway through the apply.
  • app.basic_auth: no password in the repository. A name-only user keeps the password already set with cipi basicauth enable <app> --user=NAME; a user the server does not know blocks the plan. apr1/MD5 hashes are refused. Server users not listed are removed, and basic_auth: false turns it off. cipi yml generate emits names only, never hashes.
  • redirect, redirects[] and proxies[] go through the same validation as cipi redirect and cipi proxy, checked against the whole declared set — a redirect and a proxy on the same prefix collide in the plan. The routes apply as one change: one vhost regeneration, one nginx -t, reverted as a whole if nginx refuses it.
  • Proxies are stricter than on the CLI, because anyone who can commit controls the file: there is no --force, so Cipi's own loopback ports are always refused, and link-local and 0.0.0.0/8 upstreams (cloud metadata included) are refused too, with resolved hostnames checked as well as literal IPs.

Search, recipe options, limits, HTTPS, env and crons (v5.4.0)

yaml
app:
  limits:
    memory_limit: 512M
    fpm_max_children: 10    # also octane_workers, worker_procs

search: true                # Meilisearch for Scout, like cipi search enable

deploy:
  keep_releases: 5          # 1-20
  migrate: true
  optimize: true
  storage_link: true
  queue_restart: true
  horizon_terminate: false
  extra_artisan: [ "view:clear" ]
  snapshot: true            # pre-deploy database snapshot
  post:
    - artisan cache:clear

ssl:
  force_https: true

env:
  required: [ STRIPE_KEY, MAIL_HOST ]

crons:
  - every: 30m
    run: artisan queue:prune-batches
  - cron: "15 3 * * *"
    run: php scripts/cleanup.php
  • search mints a key scoped to <app>-* and writes the SCOUT_* / MEILISEARCH_* variables. The plan is blocked until root has run cipi search install; false never drops indexes. Refused on custom apps.
  • deploy: recipe options are the set of cipi app deploy-config (extra_artisan validated, tinker refused). Only declared keys are reconciled, and a change regenerates deploy.php once. Refused on custom apps; on Node apps only keep_releases and snapshot apply.
  • app.limits reapplies cipi app limits, but a value outside the CLI's bounds blocks the plan instead of being clamped — nobody is watching an unattended apply.
  • ssl.force_https: true is cipi ssl force. The plan waits for the certificate (cipi ssl install stays with root), and it can only ever be turned on from the file: false against an app already forced is refused rather than silently ignored.
  • env.required lists names, never values. A missing or empty variable blocks the plan, so code that expects STRIPE_KEY is never deployed against a server that does not have it. Nothing is written.
  • crons: use the same allowlisted runners as deploy.post — no shell, no pipes — with every: or five cron fields. The list replaces only the crontab lines the file manages (tagged # cipi-yml); the Laravel scheduler line and the rest of the app user's crontab are never touched. artisan entries on a non-Laravel app block the plan.

node: — a Node app's build, applied by the deploy (v5.4.0)

yaml
node:
  framework: next           # next nuxt sveltekit astro remix vite — fills the rest
  mode: ssr                 # spa | static | ssr
  version: 22
  build: npm run build
  start: npx next start -H 127.0.0.1
  health_path: /
  # output: dist            # spa/static

Same presets and validation as cipi app create --node, but applied by the deploy itself, not by cipi yml apply. Right after the checkout, the recipe's node:config runs cipi yml node-sync <app> <release> as root, which reads the section from that release, and the commit that changes the build or start command is built and started with it. A mode or output change moves nginx only after current has moved; a Node major that is not installed fails the deploy before the build (installing runtimes stays with root); an invalid file leaves the server's settings in place and sends yml_fail. It needs cipi yml auto <app> on, which is also what writes the sudo rule for node-sync, and node: on a Laravel app blocks the plan. cipi yml plan lists the changes the next deploy will make.

Fixed in v5.4.0: on a custom app, workers.queues, workers.horizon: true and schedule: true now block the plan instead of producing artisan workers the app cannot run; cipi yml example no longer prints every:: command not found. cipi yml generate and cipi yml example cover every new key.

Deploys ignore the file until you opt in

Nothing happens on deploy until you run cipi yml auto <app> on. With that opt-in given, every successful deploy reconciles — from both cipi deploy and the Git webhook, the latter through one narrowly scoped sudoers rule. A release that carries no cipi.yml is a quiet no-op, and a file that fails validation is reported by email (yml_fail) and never partially applied. A successful reconcile fires yml_apply.

deploy.post — post-deploy steps that travel with the code

Available since v5.2.3. After every successful deploy, Cipi can run a declared list of allowlisted commands from the live release directory — on both cipi deploy and the Git webhook.

Unlike aliases, workers and the rest of the file, deploy.post does not require cipi yml auto <app> on. Commit the section and the steps run as soon as the release is live.

Syntax

Plain strings, or structured maps when you want the arguments spelled out. Both forms go through the same allowlist — see cipi yml example <app> for a commented template.

yaml
deploy:
  post:
    - artisan cache:clear
    - artisan scout:import --force
    - npm run build
    - composer dump-autoload -o
    - php scripts/post-deploy.php
    - node scripts/warm-cache.mjs
  # post_on_failure: abort   # default warn
yaml
# Structured form — same allowlist, useful for explicit arguments
deploy:
  post:
    - run: artisan
      command: scout:import
      args: [--force]
    - run: npm
      args: [run, build]
    - artisan: view:cache

Allowlisted runners only

There is no free shell. What is accepted:

  • artisancache:clear, migrate --force, …
  • npm / npx / yarn / pnpmrun build, ci, …
  • composerdump-autoload -o, …
  • php / node — one relative script path under the release, e.g. scripts/warm.mjs

Rejected: unknown runners, bash, pipes, ;, .. in paths, artisan tinker and the other interactive subcommands — the same spirit as cipi app run.

Where it sits in the deploy

After Deployer finishes (migrate, symlink, worker restart, …), then the optional cipi yml apply (only if auto-apply is on), then deploy.post, then the post-deploy healthcheck, then the success notification.

deploy.post_on_failure

Value Behaviour
warn (default) Log the failure and send the yml_post_fail email. The release stays live.
abort Post-deploy exits non-zero and cipi deploy fails with it — for CI, where e.g. npm run build must pass.

Testing the steps

bash
# run the declared steps now, against current/
$ cipi yml post-deploy myapp

# cipi yml plan lists them under "After deploy" — informational,
# they are not server state to reconcile
$ cipi yml plan myapp

Why it is safe to accept over Git

The file arrives from a repository, so anyone who can commit controls its contents. It is therefore fail-closed throughout:

  • It can only configure an app that already exists — never create, rename or delete one.
  • Its databases must be named <app> or <app>_*, and its backup profiles <app> or <app>-*.
  • Its healthcheck URL must resolve to one of the app's own domains — otherwise a commit could aim the server's five-minute prober at an internal address and read the answer back out of the alert emails.
  • Unknown keys are errors, and no field carries a shell command or a path to include. deploy.post (5.2.3+) and crons (5.4.0+) are the sections that run something, and they accept only allowlisted runners — never a shell line. Basic auth takes password hashes, never passwords.
  • The parser implements a deliberately small YAML subset and refuses anchors, aliases, tags, merge keys, block scalars and flow mappings outright.
Once yml auto is on, anyone who can push to that repository can change the app's aliases, PHP settings, workers, healthcheck, backup profiles, basic auth users, redirects, proxies, crons and — on a Node app — its build and start commands. That is the point of configuration-as-code — treat write access to the repo accordingly.

auth.json

Manage the auth.json file for an app. This file lives at /home/<app>/shared/auth.json and is automatically symlinked into every release by Deployer — exactly like .env. Use it to store structured credential data (e.g. API keys, feature flags, or any JSON payload) that your Laravel app can read at runtime.

bash
$ cipi auth create myapp   # create auth.json with initial { "users": [] } structure
$ cipi auth edit myapp     # open in $EDITOR (fallback: nano), validate JSON on close
$ cipi auth show myapp     # print contents formatted with jq
$ cipi auth delete myapp   # delete file (asks for confirmation)

# Non-interactive (v5.0.3+) — API / scripts / GUI
$ cipi auth create myapp --force
$ cipi auth edit myapp --file=/tmp/auth.json
$ cipi auth show myapp --json
$ cipi auth delete myapp --force

REST: GET|POST|PUT|DELETE /api/apps/{name}/auth (ability apps-auth, API 1.14+) — Composer/structured JSON, distinct from HTTP Basic Auth. MCP: AppAuthJsonShow, AppAuthJsonCreate, AppAuthJsonUpdate, AppAuthJsonDelete.

Command details

Command Description
cipi auth create <app> Creates shared/auth.json with the initial structure {"users":[]}, sets permissions to 640 (owner app:app), and adds auth.json to shared_files in the app's Deployer config so it is symlinked on every deploy.
cipi auth edit <app> Opens shared/auth.json in $EDITOR (falls back to nano). After the editor closes, validates the JSON with jq and warns if the file is malformed.
cipi auth show <app> Prints the contents of shared/auth.json formatted with jq.
cipi auth delete <app> Asks for confirmation, then deletes shared/auth.json and removes the auth.json entry from shared_files in the app's Deployer config.

Deployer integration

cipi auth create automatically appends auth.json to the shared_files list in /home/<app>/.deployer/deploy.php, and cipi auth delete removes it. This means the file is treated exactly like .env: it persists across releases and is never overwritten by a deploy.

Every cipi auth operation is logged via log_action for auditability. The AUTH section is also listed in the output of cipi help.

Git providers

Cipi auto-configures GitHub, GitLab and — since v5.2.3Cursor Origin, AWS CodeCommit, Bitbucket Cloud and Azure DevOps. Any other provider that supports SSH deploy keys still works through the manual fallback — no vendor lock-in.

The forge is detected from the SSH clone URL you give the app, never from a flag:

Provider Host detected Deploy key Webhook
GitHub github.com Automatic Automatic — HMAC secret
GitLab gitlab.com, self-hosted via cipi git gitlab-url Automatic Automatic — X-Gitlab-Token
Bitbucket Cloud (5.2.3) bitbucket.org Automatic Automatic — repo:push + HMAC secret
Azure DevOps (5.2.3) dev.azure.com, ssh.dev.azure.com, *.visualstudio.com Automatic Automatic — service hook git.push
Cursor Origin (5.2.3) origin.cursor.com Automatic Not available — deploy with cipi deploy
AWS CodeCommit (5.2.3) git-codecommit.*.amazonaws.com Automatic (IAM SSH key) Not available — deploy with cipi deploy
Anything else Gitea, Forgejo, self-hosted… Manual (cipi deploy <app> --key) Manual (cipi deploy <app> --webhook)
Origin and CodeCommit have no per-repository HTTP webhook to create — Origin Apps expose a single Ed25519-signed webhook rather than a per-repo HMAC URL, and CodeCommit only publishes to SNS/EventBridge. The deploy key is still registered automatically; releases go out with cipi deploy or from CI over SSH.

Since v5.2.3 a new app keyscans bitbucket.org, origin.cursor.com and ssh.dev.azure.com alongside github.com and gitlab.com, plus the host of the repository URL you actually gave it — so a supported forge is trusted before the first clone without any extra flag.

For self-hosted or custom Git servers, you need to trust the server's host fingerprint before Deployer can clone over SSH. Use the --trust-host flag to add the fingerprint to the app user's ~/.ssh/known_hosts automatically:

bash
# show the deploy key and add it to your Git provider
$ cipi deploy myapp --key

# trust a custom Git server fingerprint (standard port)
$ cipi deploy myapp --trust-host=git.mycompany.com

# trust a custom Git server on a non-standard port
# (also writes ~/.ssh/config automatically)
$ cipi deploy myapp --trust-host=git.mycompany.com:2222
When a non-standard port is specified, Cipi also writes the Host / Port entry to the app user's ~/.ssh/config so that Deployer can reach the server without any extra configuration.

Git auto-setup

If you save a Personal Access Token for the forge that hosts the repository, Cipi automatically adds the SSH deploy key and creates the webhook every time you run cipi app create. No manual steps required. Since v5.2.3 this covers GitHub, GitLab, Cursor Origin, AWS CodeCommit, Bitbucket Cloud and Azure DevOps.

Save a token

bash
# GitHub (fine-grained or classic PAT)
$ cipi git github-token ghp_xxxxxxxxxxxxxxxxxxxx

# GitLab (gitlab.com)
$ cipi git gitlab-token glpat-xxxxxxxxxxxxxxxxxxxx

# GitLab (self-hosted — set the URL before or after the token)
$ cipi git gitlab-url https://gitlab.example.com
$ cipi git gitlab-token glpat-xxxxxxxxxxxxxxxxxxxx

# Cursor Origin (5.2.3+)
$ cipi git origin-token <token>

# Bitbucket Cloud (5.2.3+) — bearer token, or email:token for Basic auth
$ cipi git bitbucket-token <token>
$ cipi git bitbucket-token you@example.com:<app-password>

# Azure DevOps (5.2.3+) — a PAT, sent as Basic with an empty user
$ cipi git azure-token <pat>

# AWS CodeCommit (5.2.3+) — IAM access key, secret key and IAM user name
$ cipi git codecommit-token <access-key> <secret-key> <iam-user>

Every provider has a matching remove-* command: cipi git remove-github, remove-gitlab, remove-origin, remove-bitbucket, remove-azure, remove-codecommit.

GitHub token permissions

Fine-grained tokens (recommended) need Administration and Webhooks set to Read and write on the target repositories. Classic tokens need the repo scope.

GitLab token permissions

The api scope is the minimum required — GitLab does not offer a more granular scope that covers both deploy keys and webhooks.

Cursor Origin token permissions

Sent as a Bearer token. Origin registers the deploy key; there is no per-repository webhook to create.

Bitbucket Cloud token permissions

Sent as a Bearer token, or as HTTP Basic when you store it in the email:token form. It needs to administer repository access keys and webhooks. The webhook Cipi creates subscribes to repo:push and carries an HMAC secret — the same token cipi/agent already verifies for GitHub.

Azure DevOps token permissions

A PAT with Code (read & write) — sent as Basic auth with an empty user name, which is what Azure expects. The service hook Cipi creates is a git.push subscription that sends X-Gitlab-Token, so the existing GitLab header check in the agent accepts it unchanged.

AWS CodeCommit credentials

CodeCommit is not a PAT forge: Cipi calls IAM UploadSSHPublicKey directly with curl's --aws-sigv4 signing, using the access key, secret key and IAM user name you stored. Two details follow from IAM:

  • IAM accepts RSA only, not ed25519 — so Cipi mints an extra 4096-bit id_rsa beside the usual ed25519 deploy key and uploads that one.
  • The SSH user name for CodeCommit is the SSH Key ID IAM returns, so Cipi writes a Host git-codecommit.*.amazonaws.com block into the app user's ~/.ssh/config pointing at it.

IAM allows five SSH keys per user — plan the IAM user accordingly if one account serves many apps or many servers.

Automatic lifecycle

Event What Cipi does automatically
app create Adds deploy key + creates webhook on the repository via API. The summary shows "auto-configured ✓" instead of manual instructions.
app edit --repository=... Removes deploy key + webhook from the old repository, then adds them to the new one.
app delete Removes deploy key + webhook from the repository before deleting the app.

cipi git commands

Command Description
cipi git status Show provider connection status and per-app integration details (deploy key ID, webhook ID)
cipi git refresh [app] Re-register the SSH deploy key and deploy webhook (since v5.2.1; every supported provider since v5.2.3). One named app, or every app on a supported forge. Custom/SFTP apps get the key only. Flags: --rotate-keys, --rotate-secret, --force
cipi git github-token <token> Save a GitHub Personal Access Token
cipi git gitlab-token <token> Save a GitLab Personal Access Token
cipi git gitlab-url <url> Set the base URL for a self-hosted GitLab instance
cipi git origin-token <token> Save a Cursor Origin token (v5.2.3)
cipi git bitbucket-token <token> Save a Bitbucket Cloud token — bearer, or email:token for Basic (v5.2.3)
cipi git azure-token <pat> Save an Azure DevOps PAT (v5.2.3)
cipi git codecommit-token <access-key> <secret-key> <iam-user> Save AWS IAM credentials for CodeCommit (v5.2.3)
cipi git remove-github Remove the stored GitHub token
cipi git remove-gitlab Remove the stored GitLab token and URL
cipi git remove-origin · remove-bitbucket · remove-azure · remove-codecommit Remove the stored credentials for that provider (v5.2.3)

cipi git refresh

Deploy keys and webhooks on GitHub/GitLab do not vanish when a PAT expires — they only become unmanageable via API. Refresh is for the case where they were deleted, the stored IDs in apps.json drifted, or you just installed a new token and want every app re-attached. A missing or rejected PAT is reported per app: run cipi git github-token / gitlab-token first.

bash
# every GitHub/GitLab app on this server
$ cipi git refresh

# one named app
$ cipi git refresh myapp

# mint a new ed25519 key and swap it in authorized_keys
$ cipi git refresh --rotate-keys

# mint a new CIPI_WEBHOOK_TOKEN in apps.json and shared/.env
$ cipi git refresh --rotate-secret

# skip the confirmation when rotating every app
$ cipi git refresh --rotate-keys --force

Remote leftovers titled cipi:<app> or pointing at this app's webhook URL are removed first so duplicates are not left behind. If GitHub refuses the existing pubkey because it is already a deploy key on another repo, refresh generates a new key for that app and continues. Both rotate flags ask before touching every app unless --force. Deployer still SSHs to localhost with the app's deploy key after a rotation.

Webhook egress and CrowdSec

If cipi crowdsec is enabled, the forge has to be able to reach the agent. Cipi already allowlists GitHub's webhook CIDRs from api.github.com/meta; since v5.2.3 it also pulls Bitbucket Cloud's egress ranges from ip-ranges.atlassian.com (product bitbucket, direction egress), fail-open in the same way — a fetch that does not answer never blocks the enable.

Azure DevOps and Origin do not publish a small, stable list of webhook source addresses, so there is nothing to allowlist ahead of time. If a delivery is banned, add the address by hand:

bash
$ cipi crowdsec allow 20.37.158.0/23

Manual setup (fallback)

Auto-setup is skipped when no token is configured for that provider, when the API call fails (wrong permissions, repository not found, rate limit), or when the repository is hosted somewhere Cipi does not integrate with (e.g. Gitea, Forgejo, a self-hosted server). In all these cases Cipi falls back to the manual workflow and the app creation proceeds normally.

To configure deploy key and webhook manually:

bash
# print the SSH deploy key to add to your Git provider
$ cipi deploy myapp --key

# print the webhook URL and token
$ cipi deploy myapp --webhook

# if using a custom Git server, trust the host fingerprint first
$ cipi deploy myapp --trust-host=git.mycompany.com

Then add them in your provider's repository settings:

  • Deploy key — GitHub: Settings → Deploy keys → Add deploy key; GitLab: Settings → Repository → Deploy keys
  • Webhook — GitHub: Settings → Webhooks → Add webhook; GitLab: Settings → Webhooks → Add new webhook. Set the payload URL and secret to the values shown by cipi deploy myapp --webhook
If you remove a provider token after apps have been created with auto-setup, Cipi will not be able to clean up deploy keys and webhooks when you delete or edit those apps. A warning is shown and you will need to remove them manually from the provider's repository settings.

Customising the deploy script

The deploy configuration for each app is stored at:

/home/myapp/.deployer/deploy.php

This file is auto-generated by Cipi during app create and updated automatically when you change the PHP version or deploy branch via cipi app edit. You can edit it to customise the deploy pipeline, but you should understand the implications before doing so.

Default deploy pipeline

The auto-generated deploy.php runs these tasks in order:

php
deploy:prepare          // create releases/N/ directory
deploy:vendors          // composer install --no-dev
deploy:shared           // link shared/.env and shared/storage/
artisan:migrate         // php artisan migrate --force
artisan:optimize        // php artisan optimize
artisan:storage:link    // php artisan storage:link
deploy:symlink          // swap current → releases/N/ atomically
cipi:restart-workers    // supervisorctl restart myapp-*
deploy:cleanup          // keep last 5 releases, delete older

Adding custom tasks

You can add tasks before or after any step. For a complete frontend build example (npm install && npm run build), see Building frontend assets below. Other common examples:

php
// Run artisan db:seed after migrations
after('artisan:migrate', 'artisan:db:seed');

// Clear view cache after symlink swap
after('deploy:symlink', 'artisan:view:clear');

// Custom task — send a Slack notification
task('notify:slack', function () {
    run('curl -X POST https://hooks.slack.com/... -d \'{"text":"Deployed!"}\'');
});
after('deploy:symlink', 'notify:slack');

Building frontend assets (npm / Vite)

There is no dedicated cipi CLI flag for frontend builds (e.g. npm install && npm run build). Customising deploy.php is the supported and expected approach — define a Deployer task() and hook it with after() or before(). You do not need to avoid this; extending the pipeline is exactly what the file is for.

Cipi installs Node.js and npm on the server during setup. Verify they are available as the app user:

bash
$ ssh myapp@your-server-ip
myapp@server:~$ node -v && npm -v

Commit package.json and package-lock.json to your repository. Hook the build after deploy:shared so .env is linked (Vite reads VITE_* variables from there) and before deploy:symlink so compiled assets exist in the release before it goes live.

Append the block below at the bottom of /home/myapp/.deployer/deploy.php, below Cipi's auto-generated task definitions:

php
// ── Custom: frontend build (safe zone — keep below Cipi-managed blocks) ──

task('npm:build', function () {
    cd('{{release_path}}');
    run('npm ci --no-audit --no-fund && npm run build');
});

// .env is linked → build assets → then migrations / optimize / symlink
after('deploy:shared', 'npm:build');

This runs the equivalent of npm install && npm run build on every deploy. Prefer npm ci in production when package-lock.json is committed — it is faster and reproducible. Use npm install instead only if you do not lock dependencies.

If you need separate install and build steps (e.g. to cache node_modules across releases), split them into two tasks:

php
task('npm:ci', function () {
    cd('{{release_path}}');
    run('npm ci --no-audit --no-fund');
});

task('npm:build', function () {
    cd('{{release_path}}');
    run('npm run build');
});

after('deploy:shared', 'npm:ci');
after('npm:ci', 'npm:build');

To speed up subsequent deploys, you can persist dependencies across releases by adding node_modules to Deployer's shared directories (optional — only if your project supports it):

php
add('shared_dirs', ['node_modules']);

Edit the file on the server as the app user, then test with cipi deploy myapp:

bash
$ ssh myapp@your-server-ip
myapp@server:~$ nano ~/.deployer/deploy.php
# paste the custom tasks at the bottom, save, then as root:
$ cipi deploy myapp
Alternative: run npm ci && npm run build in GitHub Actions or GitLab CI before the SSH deploy step, so the server only receives pre-built assets. See CI/CD pipelines — SSH deploy.

Running additional artisan commands

php
// Seed only in specific environments
task('artisan:db:seed', function () {
    run('{{bin/php}} {{release_path}}/artisan db:seed --force');
});
Cipi may overwrite deploy.php when you run cipi app edit myapp --php=X or cipi app edit myapp --branch=X. Back up your customisations or keep them in a section clearly separated from the Cipi-managed blocks. A safe pattern is to put all custom tasks at the bottom of the file after the default task definition.

Disabling a default step

To skip a task — for example if you handle migrations manually — comment it out or remove it from the deploy task definition:

php
// Remove the migrate step from the pipeline
task('deploy', [
    'deploy:prepare',
    'deploy:vendors',
    'deploy:shared',
    // 'artisan:migrate',  ← disabled
    'artisan:optimize',
    'artisan:storage:link',
    'deploy:symlink',
    'cipi:restart-workers',
    'deploy:cleanup',
]);

Testing your changes

After editing deploy.php, always do a test deploy before pushing to production:

bash
$ cipi deploy myapp

# If something goes wrong, instant rollback:
$ cipi deploy myapp --rollback

# If the deploy is stuck (e.g. interrupted mid-run):
$ cipi deploy myapp --unlock
The deploy log is always available at ~/logs/deploy.log or via cipi app logs myapp --type=deploy. Check it first when troubleshooting a failed deploy.

Deploy & CI/CD — Overview

With Cipi, CI (build and test) and CD (release to production) can be split or combined. Every deploy ultimately runs the same Deployer pipeline on the server — clone, composer install, migrations, symlink swap, worker restart. What changes is what triggers that pipeline.

Cipi supports two trigger models. For most Laravel apps, start with the webhook + Cipi Agent path. Move to a full CI/CD pipeline when you need gates, backups, or infrastructure orchestration that a simple push hook cannot express.

Two ways to trigger a deploy

Webhook + Cipi Agent (recommended) CI/CD pipeline via SSH
Trigger Git provider POSTs to /cipi/webhook on push GitHub Actions / GitLab CI job runs cipi deploy over SSH
Server access from CI None — only HTTPS to your app domain Dedicated SSH key stored as a CI secret
Pre-deploy tests Run locally or in a separate CI job; deploy still fires on push unless you disable the webhook Native — deploy step runs only after needs: test (or equivalent) passes
Backup before release Manual or cron on the server Pipeline job — see safe deploy
Preview / review apps Not supported out of the box Pipeline creates per-branch Cipi apps — see preview environments
Setup complexity Low — composer require cipi/agent + one webhook Medium — SSH key, secrets, workflow YAML

Which approach should I use?

Use case Recommended approach Where to read more
Single Laravel app, push-to-deploy on main Webhook + Agent Webhook setup
Deploy only if CI tests pass Pipeline SSH (disable production webhook) Pipeline SSH deploy
DB + file backup before every production release Pipeline SSH Safe deploy w/ backup
Slack / Telegram alerts on deploy outcome Pipeline SSH Deploy notifications
Ephemeral URL per feature branch (review apps) Pipeline SSH Preview environments
Deploy multiple apps on one server from one repo Either — webhook per app, or one pipeline with parallel cipi deploy Multi-app deploy
Pick one trigger per app. Do not leave a production webhook active while also running pipeline deploys on push — two concurrent Deployer runs conflict on the lock file. Use cipi deploy myapp --unlock if a stuck lock is left behind.

Automatic deploys — Cipi Agent & webhook

cipi-agent (cipi/agent) is a Laravel package that exposes POST /cipi/webhook inside your running application. When the forge sends a push event, the agent validates the payload signature, acknowledges immediately, and queues a deploy on the server — no SSH from the CI runner, no sudo, no open inbound ports beyond HTTPS. Since v5.2.3 that covers GitHub, GitLab, Bitbucket Cloud (repo:push with an HMAC secret) and Azure DevOps (a git.push service hook sending X-Gitlab-Token) — the agent's existing checks accept both unchanged.

How the webhook flow works

The design separates fast HTTP acknowledgement from slow Deployer work. A deploy can take several minutes; Git providers time out webhook HTTP calls after ~10 seconds. Cipi solves this with a flag file and the app user's crontab.

flow
  Developer                    Git provider              Your Laravel app (Cipi Agent)           Server (app user cron)
      │                              │                              │                                        │
      │  git push main               │                              │                                        │
      │ ───────────────────────────► │                              │                                        │
      │                              │  POST /cipi/webhook          │                                        │
      │                              │  (signed with secret)        │                                        │
      │                              │ ───────────────────────────► │                                        │
      │                              │                              │ 1. Verify CIPI_WEBHOOK_TOKEN           │
      │                              │                              │ 2. Check branch (CIPI_DEPLOY_BRANCH)   │
      │                              │                              │ 3. Write ~/.deploy-trigger             │
      │                              │ ◄─────────────────────────── │ 4. Return 200 immediately              │
      │                              │                              │                                        │
      │                              │                              │         every minute (* * * * *)       │
      │                              │                              │ ◄──────────────────────────────────────│
      │                              │                              │         cron sees .deploy-trigger      │
      │                              │                              │         removes file, runs Deployer    │
      │                              │                              │         in background as app user      │
      │                              │                              │                                        │
      │                              │                              │         clone → composer → migrate     │
      │                              │                              │         → symlink swap → workers       │

Deployer always runs as the app Linux user (e.g. myapp), with the correct PHP binary and file permissions — the same context as a manual cipi deploy myapp. The webhook never shells out to Deployer directly; it only drops the trigger file that Cipi's crontab already watches.

Prerequisites

Requirement Why
Cipi app created with Git repository Deploy key must clone the repo — see Git auto-setup
At least one successful manual deploy The agent package must be present in the current release before the webhook route exists
composer require cipi/agent in the project Registers the /cipi/webhook route and signature validation
Webhook URL reachable over HTTPS Git providers require a public URL; use cipi ssl install first
CIPI_WEBHOOK_TOKEN in shared/.env Auto-generated at cipi app create; shared across all releases

Step-by-step setup

1. Create the app and deploy once manually so the server can clone your repository:

bash
$ cipi app create --user=myapp --domain=myapp.com \
    --repository=git@github.com:you/myapp.git --branch=main --php=8.5
$ cipi deploy myapp

2. Install Cipi Agent in your Laravel project locally, commit, and push:

bash
$ composer require cipi/agent
$ git add composer.json composer.lock
$ git commit -m "Add Cipi Agent for webhook deploys"
$ git push origin main
$ cipi deploy myapp   # one more manual deploy until webhook is live

3. Configure the webhook. If you saved a GitHub or GitLab token, Cipi may have already created the webhook during app create — check with cipi git status. Otherwise, retrieve the URL and secret:

bash
$ cipi deploy myapp --webhook

Add the webhook in your Git provider:

Provider Payload URL Secret field Events
GitHub https://myapp.com/cipi/webhook Secret → value from --webhook Just the push event
GitLab https://myapp.com/cipi/webhook Secret token → same value Push events

4. Restrict to your deploy branch (recommended for production):

env
CIPI_DEPLOY_BRANCH=main

Set this in shared/.env via cipi app env myapp. Pushes to other branches receive a skipped response and no deploy runs.

5. Verify. Push a small commit to main and watch the deploy log:

bash
$ cipi app logs myapp --type=deploy
# or on the server as the app user:
$ tail -f /home/myapp/logs/deploy.log

Within about one minute of the webhook delivery, a new Deployer release should appear. Confirm the live commit with php artisan cipi:status or the health check endpoint.

Git auto-setup

When a token for the repository's forge is configured on the server, Cipi registers the deploy key and creates the webhook automatically on every cipi app create. The app summary shows "auto-configured ✓" instead of manual instructions. Lifecycle events (app edit --repository, app delete) keep keys and webhooks in sync. Cursor Origin and AWS CodeCommit get the deploy key only — neither exposes a per-repository HTTP webhook, so deploy those with cipi deploy or from CI.

Troubleshooting

Symptom Likely cause Fix
Webhook returns 404 Agent not deployed yet Run cipi deploy myapp after adding cipi/agent to composer.json
Webhook returns 403 / invalid signature Secret mismatch Re-copy token from cipi deploy myapp --webhook into the provider settings
200 OK but no deploy Branch filtered out Check CIPI_DEPLOY_BRANCH matches the pushed branch
Deploy stuck / lock error Previous deploy interrupted cipi deploy myapp --unlock then retry
Deploy runs twice on one push Webhook + pipeline both active Disable one trigger — see overview
The agent also supports manual and AI-triggered deploys via the MCP deploy tool — it uses the same .deploy-trigger mechanism. See Cipi Agent for health checks, MCP, and anonymizer features.

CI/CD pipelines — SSH deploy

When the webhook model is not enough, run GitHub Actions or GitLab CI/CD jobs that SSH into the server and invoke cipi deploy. This is the right choice whenever deploy must be conditional — gated on tests, preceded by backups, followed by notifications, or orchestrating new preview apps.

When you need a pipeline instead of a webhook

  • Quality gate — run php artisan test, static analysis, or frontend builds before any code reaches production
  • Safe release — snapshot the database and shared/ to S3 before swapping the symlink (safe deploy)
  • Team visibility — post success/failure to Slack or Telegram with rollback on failure (deploy notifications)
  • Review apps — create or update a full Cipi app per feature branch (preview environments)
  • Multi-app monorepo — deploy frontend and api in parallel after a single test job

For these workflows, disable the production webhook (or never create one) so only the pipeline triggers deploys. You can still use Cipi Agent in the app for health checks and MCP.

SSH access for CI

Generate a dedicated ed25519 key pair for the CI runner. Add the public key to /root/.ssh/authorized_keys on the server (or the cipi user if you prefer sudo cipi deploy) and store the private key as a CI secret. Never reuse Git deploy keys or personal SSH keys.
bash
# on your local machine
$ ssh-keygen -t ed25519 -C "ci-deploy" -f ~/.ssh/ci_deploy -N ""

# copy the public key to the server
$ ssh-copy-id -i ~/.ssh/ci_deploy.pub root@your-server-ip

# copy the private key content → add it as a CI secret (SERVER_SSH_KEY)
$ cat ~/.ssh/ci_deploy

Store SERVER_HOST (server IP or hostname) alongside SERVER_SSH_KEY in your repository secrets (GitHub) or CI/CD variables (GitLab).

GitHub Actions — test then deploy

Add the private key as a repository secret named SERVER_SSH_KEY and the server IP as SERVER_HOST.

yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: php artisan test

  deploy:
    runs-on: ubuntu-latest
    needs: test          # only deploy if tests pass
    steps:
      - name: Deploy via Cipi
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: cipi
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: sudo cipi deploy myapp

For rollback on failure, extend the script step:

yaml
          script: |
            sudo cipi deploy myapp || (sudo cipi deploy myapp --rollback && exit 1)

GitLab CI / CD

Add the private key as a CI/CD variable named SERVER_SSH_KEY (type: File) and the server IP as SERVER_HOST.

yaml
# .gitlab-ci.yml
stages:
  - test
  - deploy

test:
  stage: test
  script:
    - php artisan test

deploy:
  stage: deploy
  environment: production
  only:
    - main
  before_script:
    - apt-get install -y openssh-client
    - eval $(ssh-agent -s)
    - echo "$SERVER_SSH_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - ssh-keyscan -H $SERVER_HOST >> ~/.ssh/known_hosts
  script:
    - ssh root@$SERVER_HOST "cipi deploy myapp"

With rollback on failure:

yaml
  script:
    - ssh root@$SERVER_HOST "cipi deploy myapp || (cipi deploy myapp --rollback && exit 1)"

Multi-app deploy

If the same pipeline manages multiple apps on the same server:

yaml
# GitHub Actions — deploy multiple apps in parallel
      - name: Deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cipi deploy frontend &
            cipi deploy api &
            wait

Advanced pipeline patterns

Once SSH deploy works, compose these sections into a single production workflow:

Pattern What the pipeline adds Guide
Notifications Slack or Telegram message on success, failure, and auto-rollback Deploy notifications
Safe deploy cipi db backup + cipi backup run before cipi deploy; rollback on failure Safe deploy w/ backup
Preview environments Create/update/delete per-branch Cipi apps with wildcard DNS + SSL Preview environments

A typical mature setup uses the webhook for a staging app (instant feedback on every push) and a pipeline for production (tests → backup → deploy → notify). Each app has its own trigger — they never conflict because they target different Cipi app users.

Deploy notifications

Pipeline use case: the webhook path deploys silently — Git returns 200 and the team finds out only if they watch the logs. With an SSH pipeline, add notification steps after cipi deploy to broadcast success, failure, and automatic rollbacks to Slack or Telegram. Both examples below work with GitHub Actions and GitLab CI using only standard HTTP calls — no extra platform dependencies.

Since v5.3.0 the server can post for you — webhook deploys included: cipi notifications channel add slack ops --url=… sends every deploy_success, deploy_fail and deploy_rollback (and every other trigger) to Slack, Discord, Telegram, ntfy or a custom webhook. See chat alert channels. The pipeline examples below are still the way to go when the message should come from CI.

Slack

Add a final step that posts to a Slack webhook regardless of deploy outcome. Use if: always() in GitHub Actions so the notification fires on both success and failure.

Create an Incoming Webhook in your Slack workspace and store the URL as SLACK_WEBHOOK_URL in your CI secrets.

yaml
# GitHub Actions — deploy + Slack notification
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: cipi deploy myapp

      - name: Notify Slack — success
        if: success()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            {
              "text": ":white_check_mark: *myapp* deployed successfully",
              "attachments": [{
                "color": "good",
                "fields": [
                  { "title": "Branch",  "value": "${{ github.ref_name }}", "short": true },
                  { "title": "By",      "value": "${{ github.actor }}",    "short": true },
                  { "title": "Commit",  "value": "${{ github.sha }}",      "short": false }
                ]
              }]
            }

      - name: Notify Slack — failure
        if: failure()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            {
              "text": ":x: *myapp* deploy FAILED — rolling back",
              "attachments": [{
                "color": "danger",
                "fields": [
                  { "title": "Branch", "value": "${{ github.ref_name }}", "short": true },
                  { "title": "By",     "value": "${{ github.actor }}",    "short": true },
                  { "title": "Run",    "value": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", "short": false }
                ]
              }]
            }

      - name: Rollback on failure
        if: failure()
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: cipi deploy myapp --rollback

For GitLab CI, use curl directly — no plugin needed:

yaml
# .gitlab-ci.yml — deploy stage with Slack notification
deploy:
  stage: deploy
  script:
    - ssh root@$SERVER_HOST "cipi deploy myapp" && export DEPLOY_STATUS="success" || export DEPLOY_STATUS="failed"
    - |
      if [ "$DEPLOY_STATUS" = "success" ]; then
        curl -s -X POST "$SLACK_WEBHOOK_URL" \
          -H "Content-Type: application/json" \
          -d "{\"text\":\":white_check_mark: *myapp* deployed by $GITLAB_USER_LOGIN on \`$CI_COMMIT_REF_NAME\`\"}"
      else
        curl -s -X POST "$SLACK_WEBHOOK_URL" \
          -H "Content-Type: application/json" \
          -d "{\"text\":\":x: *myapp* deploy FAILED — <$CI_PIPELINE_URL|view pipeline>\"}"
        ssh root@$SERVER_HOST "cipi deploy myapp --rollback"
        exit 1
      fi

Telegram

Create a Telegram bot via @BotFather, get the bot token, and find your chat/group ID. Store them as TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in CI secrets.

yaml
# GitHub Actions — deploy + Telegram notification
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: cipi deploy myapp

      - name: Notify Telegram — success
        if: success()
        run: |
          curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
            -d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
            -d parse_mode="Markdown" \
            -d text="✅ *myapp* deployed successfully%0ABranch: \`${{ github.ref_name }}\`%0ABy: ${{ github.actor }}"

      - name: Notify Telegram — failure + rollback
        if: failure()
        run: |
          curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
            -d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
            -d parse_mode="Markdown" \
            -d text="❌ *myapp* deploy FAILED — rolling back%0ABranch: \`${{ github.ref_name }}\`%0A[View run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
          ssh -o StrictHostKeyChecking=no -i <(echo "${{ secrets.SERVER_SSH_KEY }}") \
            root@${{ secrets.SERVER_HOST }} "cipi deploy myapp --rollback"

GitLab CI equivalent (pure curl, no extra dependencies):

yaml
# .gitlab-ci.yml — deploy stage with Telegram notification
deploy:
  stage: deploy
  script:
    - ssh root@$SERVER_HOST "cipi deploy myapp" && RESULT="✅ deployed" || RESULT="❌ FAILED"
    - |
      curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
        -d chat_id="$TELEGRAM_CHAT_ID" \
        -d parse_mode="Markdown" \
        -d text="*myapp* ${RESULT}%0ABranch: \`$CI_COMMIT_REF_NAME\`%0ABy: $GITLAB_USER_LOGIN"
    - |
      if echo "$RESULT" | grep -q "FAILED"; then
        ssh root@$SERVER_HOST "cipi deploy myapp --rollback"
        exit 1
      fi
To find your Telegram chat ID, add the bot to the target group/channel, then call https://api.telegram.org/bot<TOKEN>/getUpdates and look for the chat.id field in the response. For private chats, just message the bot first.

Safe deploy — backup before release

For a built-in dump right before Deployer runs (no pipeline stage required), use --snapshot / --snapshot-required or enable cipi app edit <app> --predeploy-snapshot.

Pipeline use case: a webhook deploy cannot run a backup step before releasing code — the push event fires deploy immediately. In a CI/CD pipeline, add a dedicated backup stage that must succeed before deploy starts. A production-grade workflow should always create a restore point before the new code goes live. Cipi provides two complementary backup commands that map to two different safety levels:

bash
# local DB snapshot — fast, on-disk, instant rollback
$ cipi db backup myapp
# → /var/log/cipi/backups/myapp_20260303_143012.sql.gz

# S3 backup — DB dump + shared/ folder uploaded to your bucket
$ cipi backup run myapp
# → s3://your-bucket/cipi/myapp/2026-03-03_143015/db.sql.gz
# → s3://your-bucket/cipi/myapp/2026-03-03_143015/shared.tar.gz

Used together in a pipeline, they give you both a fast local restore point and an off-server copy of the database and all uploaded files. The deploy only starts if both backups succeed.

Pre-requisite: run cipi backup configure once on the server to link your S3 credentials before cipi backup run can be used. cipi db backup works without any configuration — it is always available.

What each command does internally

cipi db backup <app> calls mysqldump --single-transaction --routines --triggers and gzips the output to /var/log/cipi/backups/<app>_<timestamp>.sql.gz. The file stays on the server and is never deleted automatically — add a cleanup step or a cron if disk space matters.

cipi backup run <app> does two things: dumps the database with mariadb-dump --single-transaction into a temp dir, and archives the entire /home/<app>/shared/ folder (which contains .env, storage/, and any user-uploaded files). Both archives are then uploaded to S3 under the path cipi/<app>/<timestamp>/. The temp files are deleted after a successful upload.

GitHub Actions — safe deploy workflow

yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: php artisan test

  backup:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - name: Local DB backup
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: cipi db backup myapp

      - name: S3 backup (DB + shared)
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: cipi backup run myapp

  deploy:
    runs-on: ubuntu-latest
    needs: backup          # only runs if backup job succeeds
    steps:
      - name: Deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: cipi deploy myapp

      - name: Rollback on failure
        if: failure()
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cipi deploy myapp --rollback
            echo "Deploy failed — rolled back to previous release"

The job graph enforces the order: testbackupdeploy. If any job fails, the subsequent ones are skipped. If the deploy step itself fails, the rollback step fires automatically and restores the previous Deployer release.

GitLab CI/CD — safe deploy pipeline

yaml
# .gitlab-ci.yml
stages:
  - test
  - backup
  - deploy

variables:
  APP: myapp

.ssh: &ssh
  before_script:
    - apt-get install -y openssh-client
    - eval $(ssh-agent -s)
    - echo "$SERVER_SSH_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts

test:
  stage: test
  script: php artisan test
  only: [main]

backup-local:
  stage: backup
  <<: *ssh
  only: [main]
  script:
    - ssh root@$SERVER_HOST "cipi db backup $APP"

backup-s3:
  stage: backup
  <<: *ssh
  only: [main]
  script:
    - ssh root@$SERVER_HOST "cipi backup run $APP"

deploy:
  stage: deploy
  <<: *ssh
  only: [main]
  script:
    - |
      ssh root@$SERVER_HOST "
        cipi deploy $APP || {
          cipi deploy $APP --rollback
          echo 'Deploy failed — rolled back'
          exit 1
        }
      "
  after_script:
    - echo "Released → https://myapp.com"

backup-local and backup-s3 are in the same stage so they run in parallel if you have multiple runners, cutting overall pipeline time. Both must succeed before the deploy stage starts.

Restore from local backup

If you need to roll back the database to the snapshot taken just before the deploy:

bash
# list available local snapshots
$ ls -lh /var/log/cipi/backups/myapp_*.sql.gz

# restore the most recent one
$ cipi db restore myapp /var/log/cipi/backups/myapp_20260303_143012.sql.gz

# also roll back the code release
$ cipi deploy myapp --rollback

Restore from S3 backup

bash
# list available S3 snapshots for this app
$ cipi backup list myapp

# download the DB snapshot from S3
$ aws s3 cp s3://your-bucket/cipi/myapp/2026-03-03_143015/db.sql.gz /tmp/db.sql.gz

# restore the database
$ cipi db restore myapp /tmp/db.sql.gz

# (optional) restore shared/ files
$ aws s3 cp s3://your-bucket/cipi/myapp/2026-03-03_143015/shared.tar.gz /tmp/shared.tar.gz
$ tar -xzf /tmp/shared.tar.gz -C /home/myapp/
Local backups are never deleted automatically. Each deploy adds a new .sql.gz file to /var/log/cipi/backups/. On a busy deployment schedule, add a cleanup cron or keep only the last N files:

ls -t /var/log/cipi/backups/myapp_*.sql.gz | tail -n +6 | xargs rm -f

This example keeps the 5 most recent snapshots and deletes older ones.

Preview environments (per-branch deploy)

Pipeline use case: webhooks point at a single production URL — they cannot spin up a new Cipi app per branch. Preview environments require a CI/CD pipeline that SSHes into the server, computes a deterministic app name from the branch, and runs cipi app create or cipi deploy accordingly. Every non-production branch can get its own live URL — a fully deployed Laravel app with its own database, workers, and HTTPS. This pattern is sometimes called "review apps" or "ephemeral environments".

The URL format uses three slugs separated by hyphens, so each environment is human-readable and globally unique:

example URLs
https://develop-acmeco-3a1f9c2e.preview.domain.ltd
https://release-1-2-3-acmeco-3a1f9c2e.preview.domain.ltd
https://main-acmeco-3a1f9c2e.preview.domain.ltd

How the identifiers are generated

Three values are derived at pipeline runtime:

bash
# branch name → lowercase, non-alphanum → hyphens, trim edges
BRANCH_SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' \
  | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//')

# repo/project name → same treatment
PROJECT_SLUG=$(echo "$PROJECT" | tr '[:upper:]' '[:lower:]' \
  | sed 's/[^a-z0-9]/-/g')

# deterministic MD5 hash — same branch always gets the same environment
HASH=$(echo -n "${BRANCH_SLUG}${PROJECT_SLUG}" | md5sum | cut -c1-8)

# Cipi app username: must be lowercase alphanumeric, 3–32 chars, no hyphens
# hex chars (0–9, a–f) are valid; prefix "pr" ensures it starts with a letter
APP_NAME="pr${HASH}"    # e.g. pr3a1f9c2e

# human-readable domain with wildcard base
DOMAIN="${BRANCH_SLUG}-${PROJECT_SLUG}-${HASH}.${DEPLOY_WILDCARD_DOMAIN}"

Pre-requisites (one-time server setup)

1. DNS wildcard — add an A record *.preview.domain.ltd → <server-ip> in your DNS provider. All subdomains resolve automatically; no per-branch DNS changes needed.

2. Wildcard SSL certificate — obtain a wildcard cert via DNS-01 challenge once and install it on the server. See the Wildcard domains section for instructions. The cert path used by the pipeline examples below is /etc/letsencrypt/live/preview.domain.ltd/.

3. Repository access — the pipeline examples use an HTTPS URL with a personal access token (PAT) embedded, so no per-app SSH deploy key setup is needed. The token only needs read access to the repository.

GitHub Actions

Add these secrets to the repository: SERVER_HOST, SERVER_SSH_KEY, DEPLOY_WILDCARD_DOMAIN (e.g. preview.domain.ltd), GH_PAT (a fine-grained PAT with read access to the repo).

yaml
# .github/workflows/preview.yml
name: Preview

on:
  push:
    branches-ignore: [main, master]   # main branch uses your production pipeline
  delete:                              # clean up when a branch is deleted

jobs:
  deploy:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - name: Compute identifiers
        id: ids
        run: |
          BRANCH_SLUG=$(echo "${{ github.ref_name }}" \
            | tr '[:upper:]' '[:lower:]' \
            | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//')
          PROJECT_SLUG=$(echo "${{ github.event.repository.name }}" \
            | tr '[:upper:]' '[:lower:]' \
            | sed 's/[^a-z0-9]/-/g')
          HASH=$(echo -n "${BRANCH_SLUG}${PROJECT_SLUG}" | md5sum | cut -c1-8)
          APP_NAME="pr${HASH}"
          DOMAIN="${BRANCH_SLUG}-${PROJECT_SLUG}-${HASH}.${{ secrets.DEPLOY_WILDCARD_DOMAIN }}"
          REPO="https://oauth2:${{ secrets.GH_PAT }}@github.com/${{ github.repository }}.git"
          echo "app_name=${APP_NAME}"   >> "$GITHUB_OUTPUT"
          echo "domain=${DOMAIN}"       >> "$GITHUB_OUTPUT"
          echo "repo_url=${REPO}"       >> "$GITHUB_OUTPUT"

      - name: Create or update preview
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            APP="${{ steps.ids.outputs.app_name }}"
            DOMAIN="${{ steps.ids.outputs.domain }}"
            REPO="${{ steps.ids.outputs.repo_url }}"
            BRANCH="${{ github.ref_name }}"
            WILDCARD="/etc/letsencrypt/live/${{ secrets.DEPLOY_WILDCARD_DOMAIN }}"

            if cipi app show "$APP" &>/dev/null; then
              echo "→ Updating: $APP"
              cipi deploy "$APP"
            else
              echo "→ Creating: $APP → $DOMAIN"
              cipi app create \
                --user="$APP" \
                --domain="$DOMAIN" \
                --repository="$REPO" \
                --branch="$BRANCH" \
                --php=8.5

              # Patch nginx to listen on 443 using the pre-installed wildcard cert
              awk -v cert="$WILDCARD" '
                /^    listen 80;/ {
                  print
                  print "    listen 443 ssl http2;"
                  print "    ssl_certificate " cert "/fullchain.pem;"
                  print "    ssl_certificate_key " cert "/privkey.pem;"
                  next
                }
                { print }
              ' "/etc/nginx/sites-available/$APP" > /tmp/_cipi_vhost \
                && mv /tmp/_cipi_vhost "/etc/nginx/sites-available/$APP"
              nginx -t && systemctl reload nginx

              cipi deploy "$APP"
            fi

      - name: Print preview URL
        run: |
          echo ""
          echo "  Preview → https://${{ steps.ids.outputs.domain }}"
          echo ""

  cleanup:
    if: github.event_name == 'delete'
    runs-on: ubuntu-latest
    steps:
      - name: Compute identifiers
        id: ids
        run: |
          BRANCH_SLUG=$(echo "${{ github.event.ref }}" \
            | tr '[:upper:]' '[:lower:]' \
            | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//')
          PROJECT_SLUG=$(echo "${{ github.event.repository.name }}" \
            | tr '[:upper:]' '[:lower:]' \
            | sed 's/[^a-z0-9]/-/g')
          HASH=$(echo -n "${BRANCH_SLUG}${PROJECT_SLUG}" | md5sum | cut -c1-8)
          echo "app_name=pr${HASH}" >> "$GITHUB_OUTPUT"

      - name: Delete preview
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: root
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            APP="${{ steps.ids.outputs.app_name }}"
            if cipi app show "$APP" &>/dev/null; then
              echo "y" | cipi app delete "$APP"
              echo "→ Deleted: $APP"
            else
              echo "→ Not found, nothing to delete"
            fi

GitLab CI/CD

Add these CI/CD variables: SERVER_HOST, SERVER_SSH_KEY (File type), DEPLOY_WILDCARD_DOMAIN, GL_TOKEN (a project/group access token with read_repository scope).

yaml
# .gitlab-ci.yml
stages:
  - preview
  - cleanup

.ssh_setup: &ssh_setup
  before_script:
    - apt-get install -y openssh-client
    - eval $(ssh-agent -s)
    - echo "$SERVER_SSH_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts

.compute_ids: &compute_ids |
  BRANCH_SLUG=$(echo "$CI_COMMIT_REF_NAME" \
    | tr '[:upper:]' '[:lower:]' \
    | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//')
  PROJECT_SLUG=$(echo "$CI_PROJECT_NAME" \
    | tr '[:upper:]' '[:lower:]' \
    | sed 's/[^a-z0-9]/-/g')
  HASH=$(echo -n "${BRANCH_SLUG}${PROJECT_SLUG}" | md5sum | cut -c1-8)
  APP="pr${HASH}"
  DOMAIN="${BRANCH_SLUG}-${PROJECT_SLUG}-${HASH}.${DEPLOY_WILDCARD_DOMAIN}"
  REPO="https://oauth2:${GL_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
  WILDCARD="/etc/letsencrypt/live/${DEPLOY_WILDCARD_DOMAIN}"

deploy-preview:
  stage: preview
  <<: *ssh_setup
  except:
    - main
    - master
  script:
    - *compute_ids
    - |
      ssh root@$SERVER_HOST bash -s << ENDSSH
        APP="$APP"
        DOMAIN="$DOMAIN"
        REPO="$REPO"
        BRANCH="$CI_COMMIT_REF_NAME"
        WILDCARD="$WILDCARD"

        if cipi app show "\$APP" &>/dev/null; then
          echo "Updating: \$APP"
          cipi deploy "\$APP"
        else
          echo "Creating: \$APP → \$DOMAIN"
          cipi app create \
            --user="\$APP" \
            --domain="\$DOMAIN" \
            --repository="\$REPO" \
            --branch="\$BRANCH" \
            --php=8.5

          awk -v cert="\$WILDCARD" '
            /^    listen 80;/ {
              print
              print "    listen 443 ssl http2;"
              print "    ssl_certificate " cert "/fullchain.pem;"
              print "    ssl_certificate_key " cert "/privkey.pem;"
              next
            }
            { print }
          ' "/etc/nginx/sites-available/\$APP" > /tmp/_cipi_vhost \
            && mv /tmp/_cipi_vhost "/etc/nginx/sites-available/\$APP"
          nginx -t && systemctl reload nginx

          cipi deploy "\$APP"
        fi
      ENDSSH
    - echo "Preview → https://$DOMAIN"

cleanup-preview:
  stage: cleanup
  <<: *ssh_setup
  only:
    - branches
  when: manual                    # or trigger on MR merge via rules:
  script:
    - *compute_ids
    - |
      ssh root@$SERVER_HOST "
        APP='$APP'
        if cipi app show \"\$APP\" &>/dev/null; then
          echo 'y' | cipi app delete \"\$APP\"
        fi
      "
In GitLab, you can trigger cleanup-preview automatically when a merge request is merged by adding a rules: block that checks $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train" or using a dedicated workflow: with if: $CI_PIPELINE_SOURCE == "merge_request_event".

Notes and limits

Each preview app is a full Cipi app — it gets its own Linux user, database, FPM pool, Supervisor worker, and crontab. On a small VPS this accumulates quickly. Run cipi app list periodically and delete stale previews.

The nginx SSL patch is not idempotent — if the pipeline runs cipi app create twice (e.g. due to a retry), the awk patch will be applied again. The hash ensures APP_NAME is deterministic, so the if cipi app show guard prevents double-creation under normal conditions.

Avoid running cipi ssl install on a preview app — it will overwrite the wildcard cert config with a per-domain Let's Encrypt cert that will fail (the domain has no dedicated DNS record, only the wildcard).