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

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 onlygit, 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 --versionshould 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.
~/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/config so this project always uses this key:
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:
- GitHub: Settings → Deploy keys → Add deploy key (leave “Allow write access” off)
- GitLab: Settings → Repository → Deploy Keys
- Gitea: Settings → Deploy Keys
: between alias and path):
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.
- The lock uses
mkdir, which is atomic on local filesystems. It works whenflock(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 -xextracts the tree at the chosen SHA without the.gitdirectory tagging along.- The
execredirect 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 viacrontab -e:
*/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 fromgit pull.
Typical entries:
| Item | Why |
|---|---|
Environment file the app reads (e.g. .env) | Holds secrets. Not committed. |
| Local SQLite database and its journal/WAL siblings | The database itself. Wiping it nukes user data. |
| Runtime/storage directory the app writes to | User 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. |
.htpasswd | Basic-auth credentials, when the webroot sits behind one. |
.ftpquota | cPanel-managed quota tracking. |
error_log | Per-directory webserver error log. |
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.
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 → Domains → Create A New Domain
- Domain:
{project}.example.com - Document Root:
public_html/{project}.example.com/public
~/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:
Adding a new project
- Generate the key:
ssh-keygen -t ed25519 -f ~/.ssh/id_{project} -N "". - Add a
Host github-{project}block to~/.ssh/configwith the matchingIdentityFileandIdentitiesOnly yes. - Paste
~/.ssh/id_{project}.pubinto the repository’s Deploy Keys UI (read-only). - Verify the alias:
ssh -T git@github-{project}. - Clone with the alias:
git clone git@github-{project}:owner/{project}.git ~/repos/{project}. - In cPanel, create the subdomain
{project}.example.comwith Document Root set to the project’s public subfolder. - Copy an existing deploy script:
cp ~/scripts/deploy-existing.sh ~/scripts/deploy-{project}.sh. - Edit the new script’s
PROJECT,BRANCH,WEBROOT, andPRESERVEconstants. chmod +x ~/scripts/deploy-{project}.sh.- Add the cron line:
* * * * * /home/$USER/scripts/deploy-{project}.sh. - Push a commit to the repository and
tail -f ~/logs/deploy-{project}.logto 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:
The next cron run sees local at
HEAD~1and remote atHEAD, deploys, and you’re back in sync. -
Nuke the working clone and let cron re-clone from scratch:
Troubleshooting
A handful of commands cover most failures:git ls-remote fails from cron but works interactively, the cause is almost always one of:
IdentitiesOnly yesmissing 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/configor 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-Controlheaders from the app. Emit shortCache-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:
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.
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.

