14 min read
The usual way to get a static site from GitHub onto cPanel hosting is an FTP action: the workflow builds the site and uploads the files one at a time. It works until it does not. An upload that stops halfway leaves the live site as a mix of the old build and the new one, and the FTP password sits in your CI settings. The other common route, rsync over SSH, needs an SSH key in CI and an SSH port the CI runner can reach, which not every host allows.
cPanel already has the pieces for a better arrangement. Its Git Version Control feature can pull a branch from GitHub, and its deployment feature can run a short script that moves the files into place. Both can be triggered over cPanel's API with a token. So the workflow builds the site, pushes the result to a branch, and asks cPanel to pull and publish it. The server fetches the files itself, and CI never needs an FTP password or an SSH key.
This is how docs.redwaterhost.com is deployed: a push to the main branch builds the site, and about a minute later it is live on a cPanel account. This guide is that setup, generalized, with the mistakes we made along the way so you can skip them. It covers static sites only, meaning anything whose build produces a folder of HTML, CSS and JavaScript: Astro, Hugo, Jekyll, Eleventy, Vite, or a Next.js static export.
What you need
On the cPanel side, three features. Hosting providers can switch each of them off for an account, so check that you can see them before you start:
- Git Version Control, to clone the repository and deploy from it.
- Manage API Tokens, so CI can call the cPanel API without your password.
- Terminal or SSH access, once, to give the server a key for a private repository. A public repository does not need this, because cPanel can clone it over HTTPS.
All three are enabled on Red Water cPanel hosting accounts. If one is missing on yours, ask your host; it is a setting on their side, not something you can turn on yourself.
On the GitHub side, a repository whose build command produces a static folder, and permission to change the repository's Actions settings and secrets.
How the pieces fit
push to main
│
▼
GitHub Actions builds the site into dist/
│
├─► commits dist/ to a branch called build, and pushes it
│
└─► calls the cPanel API with a token:
1. VersionControl::update cPanel pulls the build branch
2. VersionControlDeployment::create cPanel runs .cpanel.yml
│
▼
publish.sh copies the files
into the document root
│
▼
the workflow waits until the live site serves the new build
Two details of this design matter later. The build output lives on its own branch, so cPanel
only ever downloads finished files, never your source. And cPanel pulls with --ff-only, which
its documentation states plainly:
the pull "will only succeed if the branch's HEAD commit is up-to-date or Git can fast forward it."
Every build therefore has to be committed on top of the previous one. A workflow that
force-pushes a fresh branch each time breaks the second deploy, and we found that out the hard
way.
Step 1: Add the two deployment files
Create a folder called deploy in your repository with two files. The workflow copies both into
the build, so they end up at the top of the build branch, where cPanel looks for them.
deploy/.cpanel.yml tells cPanel what to run when it deploys. cPanel's
deployment guide
requires the file to be checked in at the top level of the repository, and its tasks are shell
commands:
---
deployment:
tasks:
- /bin/bash /home/USERNAME/repositories/SITE/publish.sh /home/USERNAME/public_html
Replace USERNAME with your cPanel username, SITE with the folder you will clone into in step
3, and the last path with your site's document root. For an addon domain or subdomain, that is
the document root shown for it under Domains. Using full paths means the task does not depend
on which directory cPanel starts it in.
deploy/publish.sh does the copying:
#!/bin/bash
# Publish the checked-out build into the document root.
# cPanel runs this during a deployment, through .cpanel.yml.
set -euo pipefail
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEST="${1:?usage: publish.sh /path/to/document/root}"
DEST="${DEST%/}"
# Refuse to touch the live site with an incomplete build.
[ -d "$DEST" ] || { echo "Document root $DEST does not exist" >&2; exit 1; }
[ -s "$SRC/index.html" ] || { echo "index.html missing from the build" >&2; exit 1; }
# Copy everything except the repository's own files.
# --no-overwrite-dir keeps the document root's permissions as they are.
cd "$SRC"
tar -cf - --exclude=./.git --exclude=./.cpanel.yml --exclude=./publish.sh . \
| tar -xf - -C "$DEST" --no-overwrite-dir
# Remove files that are no longer in the build, but never the folders cPanel
# itself keeps in the document root.
cd "$DEST"
find . -type f ! -path './.well-known/*' ! -path './cgi-bin/*' | while IFS= read -r f; do
[ -e "$SRC/$f" ] || rm -f -- "$f"
done
chmod 755 "$DEST"
echo "Published $(find "$SRC" -name '*.html' -not -path '*/.git/*' | wc -l) HTML files to $DEST"
Each part of that script is there because something went wrong without it.
The exclusions keep .git out of the document root. cPanel's guide warns against deploying with
a wildcard for the same reason: "This could deploy items like the .git directory and cause serious
problems." A public .git folder hands out your full history.
The cleanup loop is what makes a renamed or deleted page disappear from the live site. Copying
alone only ever adds files, so old pages would stay reachable forever. It skips .well-known,
where AutoSSL writes certificate challenges, and cgi-bin, which cPanel creates in document
roots.
--no-overwrite-dir and the final chmod are there because of the worst twenty minutes this
setup has had. cPanel creates the repository folder with permissions that only the account
owner can read. An early version of our script copied with plain tar, which carried those
permissions onto the document root. Every file was in place, and every URL returned 404, because
the web server could no longer enter the folder. The symptom to remember: when even your own
custom 404 page is missing and you see the server's default one instead, check the document
root's permissions before assuming the files are gone.
Step 2: Add the workflow
Create .github/workflows/deploy.yml:
name: Deploy to cPanel
on:
push:
branches: [main]
permissions:
contents: write
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Build
run: |
npm ci
npm run build
- name: Publish the build branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
cp deploy/.cpanel.yml deploy/publish.sh dist/
echo "$GITHUB_SHA" > dist/deploy-id.txt
cd dist
git init -q -b build
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
# Commit on top of the previous build, so cPanel can fast-forward.
if git fetch -q origin build 2>/dev/null; then
git reset -q --soft FETCH_HEAD
fi
git add -A
if git diff --cached --quiet; then
echo "The build branch already holds this build."
else
git commit -qm "build $GITHUB_SHA"
git push -q origin build
fi
- name: Deploy on cPanel
env:
CPANEL_HOST: ${{ secrets.CPANEL_HOST }}
CPANEL_USER: ${{ secrets.CPANEL_USER }}
CPANEL_TOKEN: ${{ secrets.CPANEL_TOKEN }}
CPANEL_REPO: ${{ secrets.CPANEL_REPO }}
SITE_URL: ${{ vars.SITE_URL }}
run: |
set -euo pipefail
if [ -z "${CPANEL_TOKEN:-}" ]; then
echo "No cPanel secrets yet: the build branch is ready, skipping the deploy."
exit 0
fi
call() {
local body
body="$(curl -sS --max-time 120 \
-H "Authorization: cpanel ${CPANEL_USER}:${CPANEL_TOKEN}" \
-G "https://${CPANEL_HOST}:2083/execute/$1" "${@:2}")"
if ! printf '%s' "$body" | grep -Eq '"status": ?1'; then
echo "cPanel $1 failed:" >&2
printf '%s\n' "$body" >&2
exit 1
fi
}
call VersionControl/update \
--data-urlencode "repository_root=${CPANEL_REPO}" \
--data-urlencode "branch=build"
call VersionControlDeployment/create \
--data-urlencode "repository_root=${CPANEL_REPO}"
for i in $(seq 1 30); do
live="$(curl -fsS --max-time 10 "${SITE_URL}/deploy-id.txt" 2>/dev/null || true)"
if [ "$live" = "$GITHUB_SHA" ]; then
echo "Live: ${SITE_URL} is serving ${GITHUB_SHA}"
exit 0
fi
sleep 10
done
echo "cPanel accepted the deploy, but ${SITE_URL} still serves '${live:-nothing}'." >&2
exit 1
Adjust the build step for your project: the Node version, the build command, and dist if your
generator writes somewhere else, such as public for Hugo or _site for Jekyll and Eleventy.
A few lines deserve an explanation.
permissions: contents: write lets the workflow's built-in token push the build branch.
GitHub gives new personal repositories a token that "only has read access for the contents and
packages scopes"
(GitHub's settings documentation),
so without this line the push fails. Pushing with that token is also what stops the workflow from
triggering itself: GitHub states that when a workflow "pushes code using the repository's
GITHUB_TOKEN, a new workflow will not run"
(GITHUB_TOKEN).
concurrency makes deploys run one after another instead of interleaving against the same
document root. cancel-in-progress: false lets a running deploy finish rather than killing it
halfway through a copy.
The git diff --cached --quiet check handles re-runs. Re-running a workflow rebuilds the same
commit, which produces exactly the files already on the build branch, and git commit refuses
to make an empty commit. Without the check, every re-run fails with "nothing to commit". We hit
that one too.
deploy-id.txt is a one-line file containing the commit that was built. The last step reads
it from the live site and waits until it matches. That turns "cPanel said OK" into "the site is
actually serving this commit", and it is the most useful debugging tool in the whole setup: when
something is wrong, https://your-site/deploy-id.txt tells you which build is live without
logging in anywhere.
The deploy step skips itself while the secrets are missing. That is deliberate. Commit the
two files from step 1 and this workflow, and push. The first run builds the site and creates the
build branch, which cPanel needs to exist before you can clone it.
Step 3: Let cPanel read the repository
If your repository is public, skip to "Clone the repository" below and use its HTTPS clone URL.
For a private repository, cPanel needs a key that GitHub accepts. cPanel's guide to private repositories does this with a GitHub deploy key: a key that can read one repository and nothing else. The key lives on your cPanel account, not in CI.
Open Terminal in cPanel and generate a key without a passphrase (cPanel cannot type one for you during a pull):
ssh-keygen -t rsa -b 4096 -f ~/.ssh/mysite -C "cpanel deploy for mysite" -N ""
Tell SSH to use it for GitHub, then print the public half:
touch ~/.ssh/config && chmod 600 ~/.ssh/config
printf 'Host github.com\n IdentityFile ~/.ssh/mysite\n' >> ~/.ssh/config
cat ~/.ssh/mysite.pub
In GitHub, open the repository's Settings, then Deploy keys, then Add deploy key.
Paste the public key and leave Allow write access unchecked: cPanel only ever reads. Back in
Terminal, test it, and answer yes when SSH asks whether to trust github.com:
ssh -T git@github.com
GitHub answers that you have authenticated successfully but that it does not provide shell access, which is what you want.
Clone the repository. In cPanel, open Git Version Control and select Create. Turn on
Clone a Repository and enter the clone URL: git@github.com:you/mysite.git for a private
repository, or the HTTPS URL for a public one. cPanel does not accept a username and password in
the URL, so a private repository has to go through the deploy key.
For Repository Path, choose a new folder outside the document root, such as
repositories/mysite. It has to be empty, and it should not be the document root: the
repository holds .git and the deployment files, and publish.sh decides what becomes public.
After cloning, open Manage for the repository, set Checked-Out Branch to build, and
select Update. cPanel pulls the branch as it switches. The Repository Path shown on that
page is the value for the CPANEL_REPO secret in the next step, and the folder you write into
.cpanel.yml.
Step 4: Create the API token and the secrets
In cPanel, open Manage API Tokens under Security and create a token. Give it a name you
will recognize later, such as github-deploy-mysite. You can set an expiry date. If you do, put
it in your calendar: cPanel's documentation notes that "You cannot edit an API token expiration
date after creation", and an expired token will make every deploy fail. Copy the token now,
because cPanel shows it only once.
In GitHub, open the repository's Settings, then Secrets and variables, then Actions, and add four repository secrets:
| Secret | Value |
|---|---|
CPANEL_HOST | The server hostname you use to reach cPanel, without https:// or the port |
CPANEL_USER | Your cPanel username |
CPANEL_TOKEN | The token you just created |
CPANEL_REPO | The Repository Path from Git Version Control, for example /home/you/repositories/mysite |
Then, on the Variables tab, add SITE_URL with your site's address, such as
https://www.example.com, with no trailing slash.
Step 5: Push, and watch it land
Push any change to main. The workflow builds, pushes the build branch, asks cPanel to pull it,
asks cPanel to deploy it, and waits. The last line of the log should read
Live: https://www.example.com is serving followed by the commit hash. In cPanel, the
repository's HEAD Commit now shows a commit message beginning with build.
From here on, every push to main is a deploy.
The token can do everything your account can
Be clear about what the CPANEL_TOKEN secret is. cPanel's API reference describes an account
token as having "full access to all of a cPanel account's features". It is not limited to Git
Version Control. Anyone who can read it can manage your email, databases and files through the
API. cPanel offers a read-only flag for tokens, but both calls this workflow makes change state,
so a read-only token cannot deploy.
That makes the token a secret to treat like your cPanel password. Keep it in repository secrets, not in the workflow file. Do not echo it in logs; the workflow above passes it only in a header. Limit who has write access to the GitHub repository: anyone who can push can add a workflow that reads repository secrets. And if you ever suspect it leaked, revoke it in Manage API Tokens and create a new one; the deploy only needs the secret updated.
Forgejo and Gitea
The same setup runs on Forgejo and Gitea with small changes. It is what we use ourselves; running our own Forgejo covers that instance.
- Location. Forgejo reads workflows from
.forgejo/workflows/and falls back to.github/workflows/. Gitea checks.gitea/workflows/, then.github/workflows/. - Runners. Neither runs jobs itself. Your instance needs a registered runner, and
runs-onmust match one of its labels, such asdockerorubuntu-22.04, notubuntu-latest. - The token. Forgejo provides
secrets.FORGEJO_TOKEN, which is the same value assecrets.GITHUB_TOKEN, so the workflow above works unchanged. Its documentation says it "has write permission to the repository" and that "no workflow will be triggered as a side effect of a change authored with this token". Gitea's token issecrets.GITEA_TOKEN. permissions. Forgejo ignores it, which does no harm since its token can already push. Gitea honors it from version 1.26.concurrency. Forgejo cancels a running workflow when a new push arrives, unlesscancel-in-progressis explicitlyfalse, which the workflow above already sets. Gitea supportsconcurrencyfrom 1.26. On older Gitea it is ignored, so two quick pushes can deploy at the same time.- The push URL. Replace
github.comwith your instance's address. On Forgejo,${{ github.server_url }}holds it, which is what our own workflow uses.
For the cPanel side, register the server's public key as a deploy key in the repository's settings
on your instance, and use your instance's SSH clone URL. If your instance uses a non-standard SSH
port, add it to the Host entry in ~/.ssh/config with a Port line.
GitLab
GitLab CI follows the same plan with different syntax. The part that used to be awkward, pushing
the build branch from a job, now has a clean answer: since GitLab 18.4, the job token can push to
its own project once a Maintainer turns on Allow Git push requests to the repository in the
project's CI/CD job token settings. GitLab's
job token documentation adds that "no CI/CD
pipelines are triggered" by such a push, so there is no loop.
deploy:
image: node:22
resource_group: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- npm ci
- npm run build
- cp deploy/.cpanel.yml deploy/publish.sh dist/
- echo "$CI_COMMIT_SHA" > dist/deploy-id.txt
- cd dist
- git init -q -b build
- git config user.name "GitLab CI"
- git config user.email "ci@example.com"
- git remote add origin "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
- if git fetch -q origin build; then git reset -q --soft FETCH_HEAD; fi
- git add -A
- if git diff --cached --quiet; then echo "Already built"; else git commit -qm "build $CI_COMMIT_SHA" && git push -q origin build; fi
- cd ..
- ./deploy/cpanel-deploy.sh
Move the deploy step's script from the GitHub workflow into deploy/cpanel-deploy.sh, replacing
$GITHUB_SHA with $CI_COMMIT_SHA, and make it executable. Add CPANEL_HOST, CPANEL_USER,
CPANEL_TOKEN, CPANEL_REPO and SITE_URL under Settings, CI/CD, Variables, with
the token masked. resource_group is GitLab's way of making deploys run one at a time.
On older GitLab versions, or if you cannot enable job token pushes, a project access token with
the write_repository scope works instead. On GitLab.com those need a Premium or Ultimate plan,
and a push made with one can start a pipeline, so add [skip ci] to the build commit message.
For the cPanel side, add the server's public key under the project's Deploy keys as a read-only key.
When it breaks
"The system could not contact the remote repository." This is the error we have seen most, and
it has never once been about contacting anything. Both times, the server's copy of the build
branch could not fast-forward to the new one: once because an early version of our workflow
force-pushed a fresh branch on every build, and once because we rewrote the repository's history,
which rewrote the build branch along with it. cPanel does not document what this message
covers, so do not trust it. Ask git on the server instead, in Terminal, in the repository folder:
cd ~/repositories/mysite
git status
git fetch origin build
git log -1 --oneline origin/build
If the server's branch and origin/build have diverged, and you are sure the remote is the version
you want, reset the server's copy to it. Git Version Control in cPanel has no button for this:
git reset --hard origin/build
Then re-run the workflow. Run these commands in the repository folder, not the document root: the
two can have the same name in the shell prompt, as ours do, and git in the wrong one answers not a git repository. The
Repository Path in Git Version Control is the right one.
The deploy option is missing or disabled. cPanel deploys only from a clean working tree with a
.cpanel.yml at the top level. If you edited a file in the repository folder by hand, the tree is
no longer clean. git status shows what changed, and git checkout -- . discards it.
The deploy succeeded but the site did not change. Read cPanel's deployment log. Each deploy
writes one to ~/.cpanel/logs/ with a name like vc_TIMESTAMP_git_deploy.log, and it contains
everything publish.sh printed, including the line saying how many files it published, or the
reason it refused to.
Every page returns the server's default 404. Check the document root's permissions with
ls -ld on it. It should be drwxr-xr-x. If it is not, chmod 755 it. The script above does this
on every deploy, but it is worth knowing why.
The workflow cannot push the build branch. On GitHub, check that permissions: contents: write
is in the workflow, and that no branch rule protects build.
Where this stops
This guide deploys a folder of finished files. A PHP application, or anything that needs Composer, database migrations or a running process, needs more than a copy: dependencies installed on the server, a release that switches over in one step, and a way back if the new version fails. That is the subject of a follow-up, along with how the same idea works on the Enhance control panel.
If you would rather have a host where Git Version Control, API tokens and Terminal are already switched on, our cPanel hosting plans include all three, and they are not limited to WordPress.