CI/CD on Shared Hosting and a VPS: Real Problems, Real Solutions
Every CI/CD tutorial assumes a managed platform. None of my client projects have one — they run on shared hosting with cPanel and no root, or a single VPS somebody has to maintain. What each environment takes away from you, and a pipeline that works inside those limits.
Every CI/CD tutorial I could find assumed a managed platform that handles deployment for you. Push to main, a robot does something, your site is live. That is genuinely how a lot of work happens now — and it is not how any of my client projects deploy, because they run on a shared host with cPanel and no root, or on a single VPS somebody has to maintain.
The gap between "it just deploys" and what these environments actually permit is where the hours go. This is the field guide for both: what each environment takes away from you, and a pipeline that works inside those limits.
composer install under load, and your secrets should never pass through the pipeline.That sentence is the whole philosophy and everything below is an application of it.
Environment one
Shared hosting: you are a guest on someone else's machine
Shared hosting is the harder environment precisely because it removes your tools rather than your resources.
| What is taken away | Why it hurts |
|---|---|
| No SSH, or heavily restricted SSH | No deploy script. Some hosts give you FTP and nothing else. |
| No root, no Docker, no systemd | Container pipelines are simply off the table. |
| No long-running processes | WebSocket servers and queue workers get killed. Queues must run on cron. |
| Fixed runtimes | You get the PHP version they decide. Node apps are often unsupported entirely. |
| Cron is the only scheduler | Often with a minimum interval and no guarantee of punctuality. |
| Deploys are not atomic | FTP writes file by file. Users can and will hit a half-deployed site. |
The non-atomic problem is the one that bites in production and the one people underestimate. A 40-second upload of 900 files means 40 seconds during which your PHP autoloader can load a new class file against an old config.
Build on the runner, sync only the build output, and accept a short inconsistent window.
Mitigate it: put the app in maintenance mode as the first synced file and remove it as the last. Ugly, honest, and far better than silent breakage.
Upload to a fresh timestamped folder and flip a current symlink. The switch is one atomic operation.
This is the single biggest reliability win available on shared hosting, and many cPanel plans do allow SSH — check before assuming they do not.
name: Deploy to shared hosting
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.2' }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
# All of this happens on the runner. The host never sees composer.
- run: composer install --no-dev --optimize-autoloader --prefer-dist
- run: npm ci && npm run build
- run: php artisan test
- name: Sync build output over SFTP
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
protocol: ftps
server: ${{ secrets.FTP_HOST }}
username: ${{ secrets.FTP_USER }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ./
exclude: |
**/.env
**/.git*
**/.git*/**
**/node_modules/**
**/storage/app/**
**/tests/**
.env must be excluded or you will overwrite production credentials with whatever is in the repo — and on a bad day, publish them. storage/app must be excluded or you delete user uploads. Get these wrong once and you learn them permanently; it is much cheaper to learn them here.
Cron as the only scheduler
No daemons means no queue worker. The pattern that works is a single cron entry that runs a bounded pass:
# Every minute. Stop after 55 seconds so runs never overlap,
# and after 100 jobs so one poisoned job cannot occupy the whole window.
* * * * * cd /home/user/app && /usr/local/bin/php artisan queue:work \
--stop-when-empty --max-time=55 --max-jobs=100 >> storage/logs/queue.log 2>&1
--stop-when-empty is what makes this safe: the process exits rather than lingering, so a shared host's process reaper never has a reason to kill it mid-job. Anything needing sub-minute latency does not belong on shared hosting at all — that is a real constraint, not a workaround.
Environment two
A single VPS: root, and therefore responsibility
A VPS gives you back every tool and hands you a different problem: nothing is set up, and everything that breaks is yours.
| What you gain | What it costs |
|---|---|
| Real SSH and deploy scripts | You own SSH hardening, users and keys |
| systemd for daemons and queues | You write and maintain the units |
| Any runtime and version | You own security updates for all of them |
| Atomic symlink deploys | You build the releases layout yourself |
| Docker, if you want it | A build cache and a registry to manage |
The deploy itself is straightforward once the box is prepared. What matters is the order of operations:
-
Upload the artifact to a new release directory
releases/2026-06-09-1432/. Nothing live is touched. If the transfer fails halfway, the running site has not noticed. -
Symlink the shared state in
.env,storage/, uploads. These live outside releases and are linked into each one, so state survives deploys and never travels through the pipeline. -
Run migrations before the swap, and make them additive
The old code must keep working against the new schema for the seconds between. Add columns, do not rename them; a rename is two deploys.
-
Flip the symlink and reload
One atomic operation, then reload PHP-FPM. Without the reload, OPcache serves the old files from the old paths and you will be convinced the deploy did not happen.
-
Health check, and roll back if it fails
Hit a real endpoint. If it does not answer correctly, flip the symlink back to the previous release. Rollback is one symlink, which is the entire reason for the layout.
Choosing between them
| If you need… | Shared hosting | VPS |
|---|---|---|
| A PHP site with a database | Fine — genuinely, this is what it is for | Fine, more work |
| Background jobs within a minute | Cron, bounded | systemd worker |
| WebSockets or any daemon | Impossible | Yes |
| Atomic deploys | Only with SSH | Yes |
| Node, Python, Go | Rarely, badly | Yes |
| Nobody to maintain the OS | Yes — the host does it | No, that is now you |
Rules that hold in both
- Build on the runner, never on the server. Your production box should not be compiling assets under load.
- Secrets live on the server and are symlinked or already present. The pipeline never reads or writes
.env. - The artifact is identical across environments — only configuration differs.
- Tests gate the deploy, in the same job, before anything is transferred.
- Migrations are additive, so old and new code can both run against the schema.
- A health check after every deploy, hitting a real endpoint.
- A rollback you have actually rehearsed, not one you believe would work.
- Exclude
.env, uploads and.gitfrom every sync, explicitly. - The deploy credential can deploy and nothing else — no shell, no sudo, no database access.
- Deploy logs go somewhere you can read later, including on failure.
Where to start on Monday
Find out whether your shared host gives you SSH. That single fact decides whether you get atomic deploys or spend the next two years syncing files and hoping. It is usually a checkbox in cPanel and almost nobody checks.
- build on the runner
- ship the artifact
- symlink swap
- shared state
- additive migrations
- health check
- rollback
- bounded cron
Each half of this has its own post: shared hosting with only FTP and cron, zero-downtime deploys on a single VPS, the GitHub Actions pipeline, and — before any of it — securing the box. If your host is cPanel specifically, its built-in Git deployment is worth knowing about: that post is here.
Comments (0)
No comments yet
Be the first to share a thought on this article.
Join the conversation