RedWaterHST
← All articles

Servers, honestly

How to Deploy a Static Site from GitHub to cPanel on Every Push

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:

SecretValue
CPANEL_HOSTThe server hostname you use to reach cPanel, without https:// or the port
CPANEL_USERYour cPanel username
CPANEL_TOKENThe token you just created
CPANEL_REPOThe 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-on must match one of its labels, such as docker or ubuntu-22.04, not ubuntu-latest.
  • The token. Forgejo provides secrets.FORGEJO_TOKEN, which is the same value as secrets.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 is secrets.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, unless cancel-in-progress is explicitly false, which the workflow above already sets. Gitea supports concurrency from 1.26. On older Gitea it is ignored, so two quick pushes can deploy at the same time.
  • The push URL. Replace github.com with 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.

Share this article

Host with the people who write these guides

Managed WordPress hosting on LiteSpeed, with real engineers behind every ticket. From $7.99 a month, and you renew at the price you sign up at.

Hosted somewhere else?

We move sites in for free. Files, databases, and email, tested end to end, and you approve the copy before anything switches.