cPanel ships a Git host and a deploy runner in the box. Push a branch to a repository living in a customer's account, and a .cpanel.yml file copies the built site into the docroot — no FTP, no manual cp, no SSH babysitting. It's the closest thing to a real CI/CD flow you get on shared hosting, and most hosts never expose it to clients.
This is for agency engineers and hosts who deploy client sites onto cPanel and want a repeatable push-to-deploy pipeline. If you run Plesk, the Plesk Git deployment flow is the equivalent. The mechanics differ enough to matter.
Two ways Git Version Control runs
The feature (cPanel → Files → Git Version Control) does two unrelated jobs, and conflating them is where people get stuck.
- Create a repository directly in the account. cPanel adds a
post-receivehook, so everygit pushto it triggers a deployment. This is the push-to-deploy path. - Clone an existing remote (GitHub, GitLab) into the account. Cloned repositories do not auto-deploy on the origin's pushes — cPanel isn't the push target. You pull with Update from Remote, then run Deploy HEAD Commit manually, or script both over SSH.
Pick create-and-push if the developer controls the deploy cadence. Pick clone-and-pull if GitHub is the source of truth and a CI job drives the release.
Set up push-to-deploy
1. Create the repository outside the docroot
In Git Version Control → Create, set Clone URL empty (you're creating, not cloning), give it a Repository Path, and a name. Put it outside public_html:
/home/acmecorp/repositories/site
2. Add the cPanel remote and push
cPanel shows the clone URL after creation. From your workstation:
git remote add cpanel ssh://acmecorp@server.example.com/home/acmecorp/repositories/site
git push cpanel main
Use an SSH key loaded into the account (cPanel → SSH Access), not a password. If the first push throws remote: fatal: bad config value for 'receive.denyCurrentBranch' or the push is refused, cPanel's bundled Git binary isn't the one your client picked up. Force it:
git push cpanel HEAD --exec=/usr/local/cpanel/3rdparty/bin/git-receive-pack
3. Write .cpanel.yml
Deployment does nothing until a .cpanel.yml file sits in the repository root. It's YAML with one deployment key and a tasks list of bash commands run in order. Two-space indentation, no tabs — a tab anywhere silently aborts the deploy.
The tasks run with the working directory set to the repository's checked-out copy, as the cPanel user. There's no magic DEPLOYPATH handed to you — you define it:
---
deployment:
tasks:
- export DEPLOYPATH=/home/acmecorp/public_html
- /bin/cp -R public/* $DEPLOYPATH
- /bin/cp .htaccess $DEPLOYPATH
For a static site or a pre-built SPA, that's the whole file. Commit it, push, and cPanel runs the tasks on the post-receive hook. The Manage tab shows the last-deployed commit SHA and timestamp so you can confirm it fired.
What breaks real deploys
Dirty working tree. cPanel refuses to deploy a repository whose working tree has uncommitted changes. If a task writes into the repo checkout (a log, a cache file, a .env), the next deploy fails with a dirty-tree error. Write only into $DEPLOYPATH, never back into the repo.
CageFS-jailed commands. On a CloudLinux box, tasks run inside the user's CageFS. Only binaries proxied into the cage exist — rsync, composer, or a custom Node build tool may be missing even though they're on the host. Test each command over SSH as the user first; if it's absent, add it to CageFS or drop it from the task list.
Non-interactive only. Tasks can't prompt. Anything that waits on stdin — a password, a [y/N] confirmation, an SSH host-key prompt — hangs the deploy until it times out. Add -y, --no-interaction, or StrictHostKeyChecking=no where needed.
CRLF line endings. Edit .cpanel.yml on Windows and the CRLF endings make the Linux YAML parser choke. Force LF (git config core.autocrlf input, or an .gitattributes rule).
App restarts. Copying files into the docroot doesn't reload a running app. For a Node.js app under Passenger, touch the restart trigger as the last task; for PHP with OPcache, clear the cache so the new code is actually served:
- /bin/mkdir -p $DEPLOYPATH/tmp && /usr/bin/touch $DEPLOYPATH/tmp/restart.txt
Staging and production from one repo
Keep a single .cpanel.yml and source the environment-specific paths from a config file that lives in each account (not in Git), so the same commit deploys to staging and production without a branch-per-environment mess:
---
deployment:
tasks:
- source /home/acmecorp/deploy.cfg
- /bin/cp -R public/* $DEPLOYPATH
deploy.cfg on the staging account exports DEPLOYPATH=/home/acmecorp/staging.acmecorp.com; on production it exports the live docroot. Same repo, same YAML, different target — and secrets stay out of version control.
Driving it from GitHub
If GitHub is canonical, don't push to cPanel by hand. In a GitHub Actions workflow, SSH into the account on merge to main, cd into the cloned repo, git pull, and trigger the deploy. cPanel exposes the deploy step as a UAPI call you can run over SSH:
uapi VersionControlDeployment create repository_root=/home/acmecorp/repositories/site
Scope the Actions SSH key to that one account. For anything that touches WHM-level automation instead, mint a scoped WHM API token rather than reusing root.
FAQ
Why does my cPanel Git deployment fail with a dirty working tree?+
Does a push to GitHub auto-deploy to cPanel?+
Where do I put the .cpanel.yml file?+
Why does my push get a denyCurrentBranch error?+
Can I run npm build or composer install during deployment?+
Next steps
- Set up the same flow on Plesk if you run a mixed panel fleet.
- Add missing binaries to CageFS so your deploy tasks find
rsyncand friends. - Restart Node.js apps cleanly after a deploy under the Passenger selector.
Running these pipelines for paying clients? Keep the panel legitimately licensed — activate a cPanel license or talk to sales about volume tiers.