← Blog/23 October 2026 · 4 min read

Deploying to shared hosting without FTP: lessons from cPanel's API

Shared hosting is still the cheapest home for a small PHP app or static site. Here is how we deploy to it safely through cPanel's file API, with backups, line-ending surprises and the checks we run after every release.

DeploymentcPanelPHPDevOps

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_content writes a text file. It takes a directory, a file name and the content as form fields.
  • Fileman/upload_files uploads 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 denied

The 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 as text/plain will 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.

Next article
Using Claude Code hooks to open a page while your AI agent works →