Not every project deserves a container platform. A studio website, a small PHP tool or a landing page runs happily on the same shared hosting plan that also serves your email. The weak point of shared hosting is usually deployment: dragging files into an FTP client is slow, easy to get wrong and impossible to repeat exactly.
cPanel, the control panel most shared hosts use, exposes an HTTP API for its File Manager. We use it to deploy, and this post collects what we learned.
The two calls that matter
cPanel's modern API is called UAPI. Its endpoints live under your cPanel URL at /execute/<Module>/<function>. For deployments, two functions in the Fileman module do almost everything:
Fileman/save_file_contentwrites a text file. It takes a directory, a file name and the content as form fields.Fileman/upload_filesuploads one or more files, including binary files such as images, as a normal multipart form.
const fd = new FormData();
fd.append('dir', 'public_html/site');
fd.append('file', 'index.html');
fd.append('content', html);
const res = await fetch(`${base}/execute/Fileman/save_file_content`,
{ method: 'POST', body: fd }).then(r => r.json());
if (!res.status) throw new Error(JSON.stringify(res.errors));Every UAPI response has a status field. Check it on every call. A deploy script that ignores a failed write will happily report success while your site serves the old file.
Creating folders still needs the older API 2 (Fileman::mkdir), which is reached through /json-api/cpanel with cpanel_jsonapi_apiversion=2. It is harmless to call on a folder that already exists, so we call it for every folder on every deploy.
Lesson one: back up outside the web root
Before each deploy we read the current version of every file we are about to replace and save a copy. The first time we did this, we saved the backups next to the originals, as index.php.bak. That was a mistake: Apache does not know .bak files are PHP, so it served them as plain text to anyone who guessed the name. Our source code was briefly downloadable.
Nothing secret was in it, because credentials live in a separate config file that is denied by .htaccess, but the lesson stuck. Backups now go into a dated folder outside public_html, such as ~/backups-2026-10-23/, which the web server cannot reach at all. We also verify from outside that each backup path returns 404.
Lesson two: line endings change
Files written through save_file_content came back with Windows line endings (CRLF) when we read them again later. For HTML, CSS, JavaScript and PHP this makes no difference to how they run. It does make a difference to any deploy step that patches a file by exact string match: a search for a line ending in \n silently finds nothing.
We now normalise with content.replace(/\r\n/g, '\n') on every read, and every patch step asserts that its anchor text matched exactly once. If an anchor matches zero times or twice, the deploy stops before writing anything.
Lesson three: patch or replace, deliberately
There are two ways to update a file: send the whole new version, or read the live version and apply targeted edits. Sending the whole file is simpler and is what we do for build output, like a static site's pages. Patching is useful when the live file may contain changes you do not have locally, such as a config value edited on the server.
The rule we follow: build output is always replaced in full; hand-maintained server files are patched with anchored, assert-once edits. Never mix the two for the same file.
Lesson four: make the web server refuse what it should not serve
Shared hosting serves every file in the web root unless told otherwise. For a PHP app we keep two .htaccess layers:
# app/.htaccess
Options -Indexes
RewriteEngine On
RewriteRule ^(data/|config\.php$) - [F,L]
# app/data/.htaccess
Require all deniedThe first turns off directory listings and refuses the data folder and config file by name. The second is a backstop inside the data folder itself, in case the outer rule is ever lost.
The checks we run after every deploy
Deploying is not finished when the upload succeeds. We run the same handful of checks from outside every time:
- Every public page returns 200 and contains its expected text.
- Every private path, including the data folder, config file and old backup names, returns 403 or 404.
- Static assets come back with the right
Content-Type, because a JavaScript file served astext/plainwill not run. - One real page is loaded in a browser and the console is checked for errors.
These take seconds with curl and a short loop, and they catch the mistakes that matter: an exposed file, a missing asset, a page that silently did not update.
Summary
Shared hosting is a perfectly good home for small projects if you treat deploying as code rather than dragging files. Use the file API, check every response, back up outside the web root, normalise line endings, patch only with anchors that must match once, and verify from the outside afterwards. It is not glamorous, but it is cheap, fast and repeatable.
