Deploy & CI/CD
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.
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.
- Stop queue workers (
cipi worker stop) - Clone repo into
releases/N/ - Run
composer install --no-dev(with app's PHP) - Link
shared/.envandshared/storage/ - Run
artisan migrate --force - Run
artisan optimize - Run
artisan storage:link - Swap
currentsymlink atomically - Restart queue workers
- Prune old releases (keep last 5)
$ 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)
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.
$ 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.
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:
# 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
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
# 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:
# 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
Customising the deploy script
The deploy configuration for each app is stored at:
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:
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:
// 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
// Seed only in specific environments
task('artisan:db:seed', function () {
run('{{bin/php}} {{release_path}}/artisan db:seed --force');
});
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:
// 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:
$ 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
~/logs/deploy.log or via
cipi app logs myapp --type=deploy. Check it first when troubleshooting a failed
deploy.
CI/CD Pipelines — GitHub & GitLab
Cipi supports two integration patterns with CI/CD pipelines. Choose the one that fits your workflow.
Option A — Webhook (recommended)
The cleanest approach: install cipi-agent in your Laravel project, configure one webhook
in your Git provider, and every push to the target branch triggers a deploy automatically. The
pipeline does not need SSH access to your server.
# 1. Install the agent in your Laravel project $ composer require cipi/agent # 2. Get your webhook URL and token $ cipi deploy myapp --webhook
Then add the webhook in your Git provider (URL + token) and you are done. See the Cipi Agent — Webhook section for the full setup.
Option B — SSH deploy from the pipeline
If you want explicit control inside your pipeline (e.g. deploy only after tests pass, or only from a
specific environment), you can SSH into the server and run cipi deploy directly from
the CI job.
/root/.ssh/authorized_keys on the server and store the
private key as a CI secret. Never reuse deploy keys or personal keys.
Generate a dedicated CI key
# 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 cipi@your-server-ip # copy the private key content → add it as a CI secret $ cat ~/.ssh/ci_deploy
GitHub Actions
Add the private key as a repository secret named SERVER_SSH_KEY and the server IP as
SERVER_HOST.
# .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:
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.
# .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:
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:
# 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
cipi deploy myapp --unlock if a stuck lock is left behind.Deploy notifications
Add notification steps to any pipeline to keep the team informed on deploy success, failure, and automatic rollbacks. 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.
# 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:
# .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.
# 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):
# .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
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
A production-grade deploy pipeline 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:
# 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.
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
# .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: test → backup → deploy. 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
# .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:
# 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
# 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/
.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 -fThis example keeps the 5 most recent snapshots and deletes older ones.
Preview environments (per-branch deploy)
Every branch in your repository can automatically 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:
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:
# 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)
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).
# .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).
# .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 "
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
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).