Automating cPanel Deploys With Git: .cpanel.yml and What Actually Works
cPanel has had built-in Git deployment for years and almost nobody uses it, because the first thing everyone tries — push to GitHub and expect the site to update — is the one thing it does not do on its own. The working setup, the three gotchas that stop people, and an honest comparison against FTP.
cPanel has had built-in Git deployment for years and almost nobody uses it, because the documentation describes the buttons rather than the workflow, and the first thing everyone tries — push to GitHub, expect the site to update — is the one thing it does not do on its own.
It is genuinely good once you understand what it is: a bare Git repository on your host, a checkout, and a YAML file listing shell commands to run after that checkout. This post is the working setup, the three gotchas that stop people, and the honest comparison against just using FTP.
The model
What cPanel Git actually does
The mental model that makes everything else obvious: cPanel gives you a repository on the server and a button that means "fetch, check out, then run my script". It is not a webhook receiver, and it does not watch GitHub.
Setup
The four steps that work
-
Create the repository in cPanel, cloning from GitHub
Git Version Control › Create, tick "Clone a Repository", and give it an SSH URL. Put it in
~/repositories/app— not inpublic_html. A repository inside the document root means/.git/is downloadable and your entire source history is public. -
Add the host's SSH key to GitHub as a deploy key
cPanel generates a key under SSH Access. Add the public half to the GitHub repo as a read-only deploy key, not to your account. Read-only is enough: the server only ever pulls.
-
Commit a
.cpanel.ymlat the repository rootThis is the whole deployment. Without it the Deploy button is greyed out, which is the second most common reason people give up on this feature.
-
Trigger it from CI instead of pressing the button
The button works and is fine for a small site. For push-to-deploy, a GitHub Action calls the host over SSH to pull and deploy.
---
deployment:
tasks:
- export DEPLOYPATH=/home/myuser/public_html
- export REPO=/home/myuser/repositories/app
# Application code. -a preserves timestamps; --delete keeps the doc root
# in sync with the repo, so removed files actually disappear.
- /bin/rsync -a --delete
--exclude='.git'
--exclude='.env'
--exclude='storage/app'
--exclude='storage/logs'
--exclude='node_modules'
$REPO/ $DEPLOYPATH/
# Laravel caches, using the host's PHP binary — `php` may be the wrong one.
- /usr/local/bin/ea-php82 $DEPLOYPATH/artisan migrate --force
- /usr/local/bin/ea-php82 $DEPLOYPATH/artisan config:cache
- /usr/local/bin/ea-php82 $DEPLOYPATH/artisan route:cache
- /usr/local/bin/ea-php82 $DEPLOYPATH/artisan view:cache
# Belt and braces: make sure the repo is not reachable over HTTP.
- /bin/chmod 700 $REPO
The gotchas
Three things that stop people
1. There is no composer, and no node
Most shared plans have neither. Some have a composer binary at a strange path; almost none have a Node toolchain. So vendor/ and your built assets cannot be produced on the host.
Commit vendor/ and public/build/ to the repository.
It works. It also means every dependency update is a huge diff, your repo grows without limit, and dev dependencies ship to production unless you are careful every single time.
Build in CI and push the result to a separate deploy branch that cPanel tracks.
main stays clean source. deploy is source plus vendor/ plus built assets, written only by the pipeline, never by a person.
name: Deploy to cPanel
on:
push:
branches: [main]
concurrency: { group: cpanel-deploy, cancel-in-progress: false }
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.2' }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: composer install --no-dev --optimize-autoloader --prefer-dist
- run: npm ci && npm run build
- run: php artisan test
- name: Publish the built tree to the deploy branch
run: |
# vendor/ and public/build are gitignored on main — force them here.
git config user.name "ci"
git config user.email "ci@example.com"
git checkout -B deploy
git add -A -f vendor public/build
git add -A
git commit -m "build ${GITHUB_SHA::7}" || echo "nothing to commit"
git push -f origin deploy
- name: Ask cPanel to pull and deploy
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.CPANEL_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.CPANEL_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
ssh -p ${{ secrets.CPANEL_PORT }} ${{ secrets.CPANEL_USER }}@${{ secrets.CPANEL_HOST }} \
'cd ~/repositories/app && git fetch origin deploy && git reset --hard origin/deploy \
&& /usr/local/cpanel/scripts/cpuser_service ensure_deploy 2>/dev/null; \
/bin/bash -lc "cd ~/repositories/app && git checkout deploy"'
If your plan has no SSH at all, the fallback is the cPanel API's VersionControlDeployment::create endpoint, called with curl and an API token — same effect, one more secret to manage.
2. The document root is not the repository
The repository lives in your home directory; the site is served from public_html. The rsync line in .cpanel.yml is what bridges them, and two flags on it matter more than they look.
--delete makes the document root match the repository, so a file you removed in Git actually disappears from the server. Without it, deleted files linger forever — including old controllers and, memorably, an info.php somebody added during debugging in 2023. And every --exclude is protecting something that must survive: .env, uploads, logs. Get the exclude list right before the first deploy, because --delete without it will remove your production environment file on the very first run.
3. Laravel expects public/ to be the web root
cPanel serves public_html. The clean answer, if the panel allows it, is to point the domain's document root at public_html/public and rsync the whole application into public_html. If it does not allow it, the common workaround is rsyncing public/ to the root and the rest one level up — which works, and means your index.php paths need editing, which is a change you must remember on every future deploy. Prefer the document root change.
Honesty
Is this better than FTP?
| cPanel Git | FTP sync | |
|---|---|---|
| Post-deploy commands | Yes — migrations, caches | No |
| Deletes removed files | Yes, with --delete | Only if configured |
| Rollback | Checkout an older commit | Redeploy an old build |
| Speed | Fast — one fetch, local rsync | Slow — many small transfers |
| Needs SSH to automate | Yes (or the API) | No |
| Repo size | Grows with built assets | Not applicable |
| Atomic | No — rsync is file by file | No |
Two rows decide it. Post-deploy commands mean migrations and cache warming happen as part of the deploy rather than as a thing you remember to do — that alone is worth the setup for any framework app. And the rsync happens locally on the server, so a deploy is seconds rather than the minutes an FTP sync takes over the network.
If you want that window closed, the maintenance-flag trick from the FTP approach works here too: touch the flag as the first task in .cpanel.yml and remove it as the last.
What it still will not do
- No atomic swap. No symlink flip, because you cannot repoint the document root from a script.
- No daemons. Everything from the shared-hosting constraints still applies — queues run on cron, no WebSockets.
- Rollback needs a re-deploy of an older commit, not an instant switch.
- The deploy log is in the cPanel UI and is not especially good. A failing
.cpanel.ymltask often surfaces as a generic failure. - Repo size grows with every built-asset commit on the deploy branch. Force-pushing keeps only one commit of history there, which is why the workflow above uses
-f.
The checklist
- Repository in
~/repositories/, never insidepublic_html. - Read-only deploy key on the GitHub repo, not an account key.
.cpanel.ymlat the repo root, on the deployed branch, spaces not tabs.- Exclude
.env,storage/app,storage/logs,.gitbefore the first--deleterun. --deleteon the rsync, so removed files actually go.- Explicit PHP binary path, never bare
php. - Build in CI, push to a deploy branch — do not commit
vendor/tomain. - Document root at
public_html/publicif the panel allows it. - A trigger — an Action over SSH or the cPanel API. Nothing is automatic by default.
- Maintenance flag first task and last task, if the half-updated window matters.
Where to start on Monday
Open cPanel and look for Git Version Control and SSH Access. If both are present, you can have push-to-deploy with migrations running automatically by the end of the afternoon, which is a considerable upgrade over dragging folders — and a great deal of what people assume requires leaving shared hosting entirely.
- .cpanel.yml
- deploy branch
- rsync --delete
- deploy key
- ea-php82
- document root
- cPanel API
- maintenance flag
The pure-FTP version of this problem is here, the cron patterns for queues are in the same post, and if you find yourself wanting an atomic swap badly enough, that is the argument for moving to a VPS. The wider decision between the two is in the CI/CD guide.
Comments (0)
No comments yet
Be the first to share a thought on this article.
Join the conversation