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 all releases with timestamps
$ 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 --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)
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.

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)

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 is ready to work with GitHub and GitLab but it supports any other Git provider that supports SSH deploy keys — no vendor lock-in.

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 GitHub or GitLab Personal Access Token, Cipi automatically adds the SSH deploy key and creates the webhook on the repository every time you run cipi app create. No manual steps required.

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

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.

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 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 remove-github Remove the stored GitHub token
cipi git remove-gitlab Remove the stored GitLab token and URL

Manual setup (fallback)

Auto-setup is skipped when no token is configured, when the API call fails (wrong permissions, repository not found, rate limit), or when the repository is hosted on a provider other than GitHub or GitLab (e.g. Gitea, Forgejo, Bitbucket). 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. 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');

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 GitHub or GitLab 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.

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 GitHub or GitLab token 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.

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.

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

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).