Skip to main content

Documentation Index

Fetch the complete documentation index at: https://help.noxity.io/llms.txt

Use this file to discover all available pages before exploring further.

Updated: May 17, 2026
Matic Bončina
By Matic BončinaFounder
Review pending

Overview

One cPanel account hosting several subdomains, each backed by its own remote git repository, deployed independently. Cron polls each project once a minute and runs a small bash script. The script is a no-op when local HEAD already matches the remote, and otherwise wipes the webroot, extracts the new tree, and restores a per-project preserve list. Pick this pattern when you have a handful of small sites under one shared-hosting account and don’t want to maintain a long-lived CI runner per project. It needs only git, bash, cron, and SSH on the server.

Why this pattern

  • Per-repo deploy keys. One SSH key per repository. If a key leaks, the blast radius is one repo, not the whole account or every site you host.
  • Per-project isolation. Each project has its own script, lock file, log, and working clone. A broken deploy in one project can’t poison another.
  • Easy rotation. Regenerate the key on the server, paste the new public key into the provider, replace the old file. Rotation is a minute of work per repo.
  • No shared secret across projects. Nothing to revoke account-wide if a single key is compromised.
  • No background services. It’s bash and cron. No daemon to keep alive on shared hosting where you can’t run systemd, no webhook listener to expose to the open internet, no CI license to renew.

Prerequisites

  • SSH access to the cPanel account. Many hosts gate this behind a settings page; enable it before continuing.
  • Git available on the server. git --version should return cleanly.
  • Cron jobs enabled in cPanel.
  • The ability to set a custom Document Root when creating subdomains (standard in modern cPanel).
  • A repository on a provider that supports per-repo deploy keys (GitHub, GitLab, Gitea, Bitbucket).
  • An empty subdomain in cPanel for each project you’ll deploy.

Directory layout

Everything lives under the cPanel user’s home directory. {project} and $USER are placeholders to substitute per project.
/home/$USER/
├── .ssh/
│   ├── config                  # per-host aliases
│   ├── id_{project}            # private deploy key (mode 600)
│   ├── id_{project}.pub        # public deploy key (mode 644)
│   └── known_hosts
├── repos/
│   └── {project}/              # working clone, never served directly
├── public_html/
│   └── {project}.example.com/  # subdomain webroot
├── scripts/
│   └── deploy-{project}.sh     # one script per project (mode 755)
└── logs/
    └── deploy-{project}.log    # one log per project
The working clone in ~/repos/{project} is what cron pulls into. The webroot in ~/public_html/{project}.example.com/ is what the webserver serves. The deploy script copies the relevant tree from the first into the second.

One-time per project: SSH deploy key + host alias

Generate a passphrase-less ed25519 key. Cron runs unattended, so a passphrase isn’t workable. Isolation comes from one key per repo, not from a passphrase.
ssh-keygen -t ed25519 -f ~/.ssh/id_{project} -C "deploy-{project}@$USER" -N ""
Add a host alias block to ~/.ssh/config so this project always uses this key:
Host github-{project}
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_{project}
    IdentitiesOnly yes
IdentitiesOnly yes is the load-bearing line. Without it, SSH offers every key in ~/.ssh/ to the server in turn; on a busy account that hits the provider’s auth rate limit and the connection eventually fails with a generic permission error. With it, only the named key is offered. Lock down permissions or ssh will refuse to use the files:
chmod 600 ~/.ssh/config ~/.ssh/id_{project}
chmod 644 ~/.ssh/id_{project}.pub
Print the public key:
cat ~/.ssh/id_{project}.pub
Paste the output into the repository’s deploy keys page. The setting lives at:
  • GitHub: Settings → Deploy keys → Add deploy key (leave “Allow write access” off)
  • GitLab: Settings → Repository → Deploy Keys
  • Gitea: Settings → Deploy Keys
Verify the alias resolves to the right account:
ssh -T git@github-{project}
You should get a greeting confirming authentication. Then clone using the alias (note the : between alias and path):
git clone git@github-{project}:owner/{project}.git ~/repos/{project}
If you ever switch providers, only the HostName line and the deploy-key UI step change. The script and cron don’t notice.

The deploy script

Save as ~/scripts/deploy-{project}.sh and chmod +x it. The body below is self-contained. Replace {project} and example.com with the real values for each project.
#!/usr/bin/env bash
# Deploys {project} when origin HEAD moves. No-op otherwise.

set -euo pipefail

PROJECT="{project}"
BRANCH="main"
REPO_DIR="$HOME/repos/$PROJECT"
WEBROOT="$HOME/public_html/${PROJECT}.example.com"
LOCK="/tmp/deploy-${PROJECT}.lock"
LOG="$HOME/logs/deploy-${PROJECT}.log"

# Files/directories at the webroot to preserve across deploys.
# See the "Preserve list" section: anything the repo can't reproduce.
PRESERVE=(
    ".env"
    "storage"
    "database.sqlite"
    "database.sqlite-journal"
    "database.sqlite-wal"
    "database.sqlite-shm"
    "cgi-bin"
    ".well-known"
    ".htpasswd"
    ".ftpquota"
    "error_log"
)

mkdir -p "$(dirname "$LOG")"
exec >>"$LOG" 2>&1

ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
log() { echo "[$(ts)] $*"; }

# Single-runner lock. flock(1) isn't always installed on shared hosts;
# mkdir is atomic and gives the same guarantee.
if ! mkdir "$LOCK" 2>/dev/null; then
    log "lock held; skipping"
    exit 0
fi
trap 'rmdir "$LOCK"' EXIT

cd "$REPO_DIR"

# Cheap remote check; exit before doing any heavy work.
LOCAL_SHA=$(git rev-parse "$BRANCH")
REMOTE_SHA=$(git ls-remote origin "refs/heads/$BRANCH" | awk '{print $1}')

if [[ "$LOCAL_SHA" == "$REMOTE_SHA" ]]; then
    exit 0  # no-op; ~50 ms total
fi

log "deploy: $LOCAL_SHA -> $REMOTE_SHA"

git fetch --quiet origin "$BRANCH"
git reset --hard "origin/$BRANCH"

# Snapshot the preserve list to /tmp before wiping the webroot.
STASH=$(mktemp -d "/tmp/preserve-${PROJECT}.XXXXXX")
for item in "${PRESERVE[@]}"; do
    if [[ -e "$WEBROOT/$item" ]]; then
        cp -a "$WEBROOT/$item" "$STASH/"
    fi
done

# Wipe the webroot. Excluding only the preserve list would still leak
# orphan files from older deploys; the wipe is intentionally total.
find "$WEBROOT" -mindepth 1 -delete

# Extract the working clone (no .git noise) into the webroot.
git -C "$REPO_DIR" archive --format=tar "$REMOTE_SHA" | tar -x -C "$WEBROOT"

# Restore the preserve list.
for item in "${PRESERVE[@]}"; do
    if [[ -e "$STASH/$item" ]]; then
        cp -a "$STASH/$item" "$WEBROOT/"
    fi
done
rm -rf "$STASH"

# ----- Post-deploy hook -----
# Run any post-deploy steps here: dependency install, cache refresh,
# migrations, asset rebuild. Leave commented if not needed.
#
# cd "$WEBROOT"
# ./bin/post-deploy
# ----------------------------

log "deployed $REMOTE_SHA"
A few notes on the shape:
  • The lock uses mkdir, which is atomic on local filesystems. It works when flock(1) isn’t available, which is common on shared hosts.
  • The no-op check uses git ls-remote. One network round-trip, no objects fetched. On a no-op run the whole script exits in around 50 ms.
  • git archive | tar -x extracts the tree at the chosen SHA without the .git directory tagging along.
  • The exec redirect at the top sends everything from this point on (including post-deploy hook output) to the project’s log.

Cron entries

One line per project, added through cPanel’s Cron Jobs editor or via crontab -e:
* * * * * /home/$USER/scripts/deploy-projectA.sh
* * * * * /home/$USER/scripts/deploy-projectB.sh
* * * * * /home/$USER/scripts/deploy-projectC.sh
Minute-level polling is fine. The no-op path described above costs about 50 ms; 1440 polls a day per project works out to roughly a minute of CPU per project per day. The fetch-and-extract path only fires on actual pushes. If you want gentler polling, change the schedule (*/2 * * * * for every two minutes). Cron syntax is identical on every cPanel host.

Preserve list

The principle: anything the repository can’t reproduce belongs on the preserve list. Lose it on a deploy and there’s no recovering it from git pull. Typical entries:
ItemWhy
Environment file the app reads (e.g. .env)Holds secrets. Not committed.
Local SQLite database and its journal/WAL siblingsThe database itself. Wiping it nukes user data.
Runtime/storage directory the app writes toUser uploads, generated thumbnails, cache files, session blobs.
cgi-bin/cPanel-managed system directory.
.well-known/ACME challenge files for Let’s Encrypt. Removing them breaks SSL renewals.
.htpasswdBasic-auth credentials, when the webroot sits behind one.
.ftpquotacPanel-managed quota tracking.
error_logPer-directory webserver error log.
Audit the preserve list any time the application’s storage layout changes. If a new release writes to a new directory, add it to the array before the next deploy.

Clean-wipe vs merge

Two strategies for moving the new tree into the webroot:
  • Merge. Copy the new files on top of the existing ones (cp -r repo/* webroot/). Fast and harmless-looking, but it leaves behind any file that existed in a previous deploy and was later removed from the repository. Those orphans keep getting served by the webserver indefinitely. If the application’s layout ever changes significantly (directory renames, file relocations, deletes), the old files quietly stay live alongside the new ones, and the webserver may pick either depending on how routing is configured.
  • Clean-wipe. Snapshot the preserve list out of the webroot, delete everything else, extract the new tree, restore the preserve list. Slightly more I/O on every deploy, but the webroot is always exactly the repository at HEAD plus the explicitly preserved entries.
The script above uses clean-wipe. Stick with it unless you have a concrete reason to prefer merge.

Document root caveat

Many application layouts expect the public-facing directory to be a subfolder of the project, not its top level. Common names: public/, dist/, build/, out/. The clean way is to point the subdomain at the subfolder when you create it in cPanel:
  • cPanel → DomainsCreate A New Domain
  • Domain: {project}.example.com
  • Document Root: public_html/{project}.example.com/public
The repository’s tree extracts to ~/public_html/{project}.example.com/ (the project root), and the webserver serves out of the public/ subfolder. The project root is reachable from disk for your scripts, the public subfolder is reachable from the web for visitors. When you can’t change the document root after the fact (some panels don’t expose it post-creation), the uglier fallback is an .htaccess rewrite at the project root:
# .htaccess at the project root
RewriteEngine On
RewriteRule ^$ public/ [L]
RewriteRule (.*) public/$1 [L]
This works but adds a moving part. Prefer the document-root approach when the panel lets you set it.

Adding a new project

  1. Generate the key: ssh-keygen -t ed25519 -f ~/.ssh/id_{project} -N "".
  2. Add a Host github-{project} block to ~/.ssh/config with the matching IdentityFile and IdentitiesOnly yes.
  3. Paste ~/.ssh/id_{project}.pub into the repository’s Deploy Keys UI (read-only).
  4. Verify the alias: ssh -T git@github-{project}.
  5. Clone with the alias: git clone git@github-{project}:owner/{project}.git ~/repos/{project}.
  6. In cPanel, create the subdomain {project}.example.com with Document Root set to the project’s public subfolder.
  7. Copy an existing deploy script: cp ~/scripts/deploy-existing.sh ~/scripts/deploy-{project}.sh.
  8. Edit the new script’s PROJECT, BRANCH, WEBROOT, and PRESERVE constants.
  9. chmod +x ~/scripts/deploy-{project}.sh.
  10. Add the cron line: * * * * * /home/$USER/scripts/deploy-{project}.sh.
  11. Push a commit to the repository and tail -f ~/logs/deploy-{project}.log to confirm the deploy fires within a minute.

Forcing a redeploy

Sometimes the local clone and the remote agree on the SHA but the webroot drifted (manual edit, partial deploy, half-finished restore). Two ways to force the next cron tick to redeploy:
  • Rewind the local clone so the no-op check fails:
    cd ~/repos/{project}
    git reset --hard HEAD~1
    
    The next cron run sees local at HEAD~1 and remote at HEAD, deploys, and you’re back in sync.
  • Nuke the working clone and let cron re-clone from scratch:
    rm -rf ~/repos/{project}
    git clone git@github-{project}:owner/{project}.git ~/repos/{project}
    
The first is faster. The second is the sledgehammer when something more structural is wrong (the working clone got corrupted, the remote URL changed, the branch name changed).

Troubleshooting

A handful of commands cover most failures:
# Tail the per-project deploy log
tail -f ~/logs/deploy-{project}.log

# List active locks (one directory per running deploy)
ls -la /tmp/deploy-*.lock

# Clear a stale lock left by a script that died mid-deploy
rmdir /tmp/deploy-{project}.lock

# Verify the SSH alias and key combo
ssh -T git@github-{project}

# Show the current user's cron table
crontab -l

# Check the working clone's remote URL. It must use the alias, not vanilla github.com.
git -C ~/repos/{project} remote -v
If git ls-remote fails from cron but works interactively, the cause is almost always one of:
  • IdentitiesOnly yes missing from the Host block (SSH offered the wrong key first and got rate-limited).
  • The deploy key was pasted into the wrong repository, or against the wrong account.
  • ~/.ssh/config or the private key has permissions wider than 600 and SSH refuses to use them.

CDN/cache gotcha

If the subdomain sits behind a CDN, the origin starts serving fresh content the moment the deploy script finishes. Visitors still get the cached page for as long as the CDN’s TTL allows. Three ways to keep the origin and the public view in sync:
  • Purge after deploy. Most CDNs expose an API endpoint or a CLI for invalidating a URL prefix. Wire the call into the post-deploy hook of the script. Cheapest and most reliable for sites that ship infrequent, large updates.
  • Cache-Control headers from the app. Emit short Cache-Control: max-age=... headers on HTML, and longer TTLs on hashed asset URLs. The CDN drops stale HTML on its own as soon as the TTL expires, with no manual purge. Best for sites that deploy often.
  • Cache-busting query string for verification. When checking a deploy from your own machine, append a junk parameter:
    curl -sI "https://{project}.example.com/?nocache=$(date +%s)"
    
    Most caches treat each unique query string as a separate cache key, so the request hits the origin. Useful for confirming the deploy worked while the public cache is still warming up. Not a substitute for one of the first two for real visitors.
The first two are the durable fix. The third is a verification tool, not a production strategy.

Need a hand?

Open a ticket

Best for anything that needs an account check or a config change on our end.

Live chat

Faster for quick questions during business hours.