Automatic deployments to cPanel with GitHub Actions
In the era of agentic development, version tracking is a must. Agents produce non-deterministic output, and the only sane way to work with that is a history you can inspect and revert. With that comes the need for automated deployments and rollbacks, and lucky for us this existed long before the agents came. Its name is CI/CD.
What is CI/CD and why AI agents need it?
Continuous Integration and Continuous Deployment (or Delivery) is a software development method that automates the whole process of building, testing and deploying in a fast, reliable and repeatable way. In simpler words: the developer writes the code, commits it to Git, pushes it somewhere, and that push triggers the pipeline. The code gets tested and, if all lights are green, deployed.
In the agentic era, where the developer supervises agents instead of typing every line, CI/CD has never been more needed:
- Agents mess up sometimes (still). Version control and a one-command rollback are a must.
- The agent never needs the keys. It writes the code and the pipeline; GitHub holds the credentials; the server only ever talks to the pipeline. You review every change before it ships, and the agent cannot ship anything on its own.
- Tokens cost money. Why spend them on repetitive upload work when all the agent has to do is commit and push.
Setting up basic CI/CD with cPanel and GitHub over SFTP
Enter Deployer 9000 (DEP 9000), our open-source GitHub Action for deployments over FTP, SFTP and rsync. All you need is one workflow file in your repository and a few secrets. So let’s get started.
This guide assumes you know your way around Git and pushing commits to GitHub. The repository can be private, that changes nothing below. SFTP needs SSH access on your cPanel account.
1. The workflow file
In your repository, create .github/workflows/deploy.yml with the following content:
name: Deploy
on:
push:
branches: [ "main" ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Code Deployment
uses: Black-HOST/deployer@v1
with:
protocol: sftp
server: ${{ secrets.SFTP_HOST }}
port: ${{ secrets.SFTP_PORT }}
username: ${{ secrets.SFTP_USER }}
ssh_key: ${{ secrets.SFTP_KEY }}
# the key password, if the key has one; an empty secret is ignored
password: ${{ secrets.SFTP_KEY_PASS }}
# SFTP paths are real paths, relative to the login's home directory
remote_dir: "public_html"
# a custom list replaces the default one, so the dotfile rules stay in front
exclude: ".*,.*/,*.md"
Every push to main now runs this job: check out the repository, hand it to DEP 9000, upload it to public_html over SFTP. You might have noticed the secrets. references. Those are the credentials, and they never live in the file. We create them in step 3; first we need a key.
2. The deploy key
Instead of a password, the pipeline logs in with an SSH key that exists only for this purpose. If someone leaves the project, you delete the key in cPanel and the pipeline is locked out, nothing to rotate.
In cPanel, open SSH Access > Manage SSH Keys > Generate a New Key. Give it a name such as deploy_key, choose RSA with 4096 bits and click Generate Key as shown bellow.

cPanel generates RSA keys only. The modern standard for SSH keys is ed25519: shorter, faster, nothing to size. If you prefer it, you cab create the key on your computer with the following command, assuming you have `OpenSSH` installed:
ssh-keygen -t ed25519 -f deploy_key -N "" -C "github deploy"
This gives you deploy_key (private, goes to GitHub) and deploy_key.pub (public, goes to the server). Do not add either to the repository. In cPanel, open Manage SSH Keys > Import Key, paste the content of deploy_key.pub as the public key and click Import.
Either way, the last step is the same: under Manage SSH Keys > Public Keys, click Manage next to the key, then Authorize. Until you do, the key exists but cannot log in.

3. The secrets
Head to Settings > Secrets and variables > Actions in your GitHub repository and create these:
| Secret | Value |
|---|---|
SFTP_HOST | your domain, or the server hostname shown in cPanel |
SFTP_PORT | the SSH port, 22 unless your host says otherwise |
SFTP_USER | your cPanel username |
SFTP_KEY | the private key as text, not a file name: paste the whole content, BEGIN and END lines included, from Manage SSH Keys > Private Keys > View/Download in cPanel, or from the deploy_key file if you created it yourself |
SFTP_KEY_PASS | the key password, only if you set one for your SSH key |
Only the key and its password are real secrets. We keep the rest as secrets too, so there is one place to look. If you prefer, host and port can be repository variables instead (server: ${{ vars.SFTP_HOST }}), which are visible in the settings and in the logs. That helps when a connection fails, because secret values are masked as *** in the log. At the end you should get something like this in your GitHub settings:

4. Push
Commit the workflow file and push. The Actions tab in your repository shows the run; when it turns green, the site is live. The first deploy uploads everything; from then on, every push to main is a deployment.
What the workflow actually does
A few lines in that file deserve a second look, because they are where cPanel deployments usually go wrong.
remote_dir: "public_html"has no leading slash on purpose. Over FTP,/is your account’s home directory. Over SFTP,/is the root of the server’s filesystem, and/public_htmldoes not exist. SFTP paths are relative to the home directory, sopublic_htmlis your web root. An addon or sub domain lives in its own folder; use that instead.excludereplaces the default list. DEP 9000 skips dotfiles,node_modules/and*.logout of the box, so.git/and.envnever reach the web server. The moment you set your ownexclude, that default is gone, which is why the example repeats.*,.*/before adding*.md. Need.htaccesson the server? Addinclude: ".htaccess"; included files are uploaded even though the dotfile rule excludes them.- Nothing is deleted on the server unless you ask. Add
delete: "true"and files that no longer exist in the repository are removed on deploy. - Every deploy uploads every file, because a CI checkout gives all files the time of the checkout. Add
preserve_times: "true"and DEP 9000 restores the commit time of each file first, so only the files that changed are uploaded. On a site with a few hundred files this turns a minute into seconds. ssh_keytakes the key itself, the text of the private key file, not a path to it. The deployer writes it to a file inside the job’s container, which is thrown away when the job ends.- Optional, for more security:
host_key. By default the deployer trusts whatever host key the server presents. Pin it (host_key: "ssh-ed25519 AAAA...", fromssh-keyscan -p <port> -t ed25519 <server>run on a network you trust) and any other server is refused before the key or a single file is sent.
Rolling back
This is the part the intro promised. The agent shipped something ugly, the site is broken, and it is 23:40. In the repository:
git revert HEAD
git push
The revert is a new commit that undoes the last one, the push triggers the pipeline, and a minute later the previous version is live. No FTP client, no “which files did it touch”, no restoring from a backup. If the bad commit added files that should disappear too, that is what delete: "true" is for. A rollback is just another commit, which also means it is in the history, reviewable, and revertable itself.
The alternative will be to re-trigger deploy action on specific commit that we know that we know everything was ok.
Let your agent do it
You are building with Claude Code, Cursor or a similar tool? Then the setup above is the agent’s job, not yours. We’ve created the CI/CD playground, simple repo where all the examples live. Each branch is an example on its own for different protocols and various setups. Once you pick your preferred branch/method you can head down to the section Let your agent do it copy the prompt for your agent and the agent takes over. The private key goes into a GitHub secret, never into the chat or the repository. From that moment, “deploy” means “commit and push”, and that is something an agent can be trusted with.
Where to go from here
This article pretty much covers the basics of DEP 9000 capabilities, so if your setup needs something more advanced you can find all the test cases we’ve been testing DEP 9000 against in in its automated workflows: https://github.com/Black-HOST/deployer/tree/master/.github/workflows. Yep we do tests on all changes and we plan to keep this product as boring as possible by staying backward compatible within all of its versions.
In case that your cPanel account dosent have SSH access, then in our CI/CD playground you can find an FTP example that does the same over FTPS with a password.
Or if you are deploying from GitLab, then we have DEP 9000 on Docker Hub as blackhost/deployer for now the DEP 9000 README has the instructions on how can you setup this with .gitlab-ci.yml.