Zero-Downtime Laravel Deployments on cPanel with GitHub Actions

Zero-downtime deployment on cPanel isn’t magic, it’s structure: timestamped releases, shared storage, and one atomic symlink switch that makes every deploy instant, safe, and rollback-ready

Zero-Downtime Laravel Deployments on cPanel with GitHub Actions
On this page
  1. The Problem With Naive Deployments
  2. The Directory Structure
  3. The cPanel Problem — Why a Symlink Was Necessary
  4. SSH Setup
  5. The GitHub Actions Workflow
  6. How User Uploads Are Preserved
  7. What a Deploy Looks Like
  8. Troubleshooting: The "Index of /" Problem
  9. What causes it
  10. The failed approach — custom .htaccess rewrites
  11. The correct fix — symlink the document root
  12. What about the .htaccess?
  13. If you still get a 403 after all this
  14. Wrapping Up

There's a specific kind of frustration that comes with deploying on shared hosting. You push a fix, files start transferring, and for the next thirty seconds your app is in a half-deployed limbo — broken routes, missing assets, confused users. If you're lucky, it's late at night and nobody's online. If you're not, well.

I ran into this while setting up the deployment pipeline for AHOBA07, a community platform I'm building for the All Hallows Old Boys Association (Class of 2007). The stack is Laravel 12 + Vue 3, hosted on cPanel. I wanted proper deployments — the kind where switching to a new release is instant, uploaded files are never touched, and every deploy leaves a rollback point behind it.

This article documents exactly what I built and why. It picks up where two excellent guides by Stephen Jude leave off:

Stephen's guides get you connected and deploying. This one picks up where they leave off — what happens when you need zero-downtime releases, proper environment handling, and a deployment structure that doesn't blow away your users' uploaded images every time you ship a feature.

The Problem With Naive Deployments

The simplest approach is to rsync your entire project directly into your public_html folder on every push. It works, but it has real problems:

  • Downtime. While files are being transferred, your app is in an inconsistent state.
  • Overwritten uploads. If you exclude storage/ from the rsync, the directory disappears. If you don't exclude it, you risk overwriting user-uploaded files.
  • No rollback. If something goes wrong, you're manually restoring from a backup.

The solution is a deployment pattern borrowed from tools like Deployer and Capistrano — timestamped releases with a symlink pointing to the current one.

The Directory Structure

Before touching the workflow, the first step is setting up this structure on the server:

/home/ahobacom/ deployments/ releases/ 2026-03-27_14-16-49/ ← previous release 2026-03-27_15-45-00/ ← current release current/ ← symlink → latest release shared/ .env ← persists across every deploy storage/ ← user uploads, logs — never touched

Every deploy creates a new timestamped folder inside releases/. The current symlink always points to the latest one. Shared files — your .env and the storage/ directory — live outside the releases entirely and get symlinked in on each deploy.

Run these commands once to set it up:

mkdir -p /home/ahobacom/deployments/releasesmkdir -p /home/ahobacom/deployments/shared/storage
# Move your existing .env and storage to sharedcp /home/ahobacom/public_html/test.ahobao7.com/AHOBAO7-portfolio/.env \ /home/ahobacom/deployments/shared/.env
cp -r /home/ahobacom/public_html/test.ahobao7.com/AHOBAO7-portfolio/storage \ /home/ahobacom/deployments/shared/

My first instinct was to point cPanel's document root directly at deployments/current/public. Clean, no tricks, done. But cPanel's domain management UI only accepts paths inside /public_html/. It simply won't let you type an absolute path like /home/ahobacom/deployments/current/public.

So I couldn't tell cPanel "serve files from my deployments folder" — the interface wouldn't allow it.

The workaround is to leave the document root as /public_html/test.ahobao7.com — exactly where cPanel expects it — but replace that folder with a symlink pointing to deployments/current/public:

ln -sfn /home/ahobacom/deployments/current/public \ /home/ahobacom/public_html/test.ahobao7.com

cPanel thinks it's serving from public_html like normal. Apache transparently follows the symlink into your actual Laravel public folder. You're essentially tricking cPanel into pointing where you need it to go.

Before committing to this, it's worth verifying that your host actually allows symlinks pointing outside public_html — not all of them do. You can test it quickly:

ln -s /home/ahobacom/deployments /home/ahobacom/public_html/test.ahobao7.com/test-symlinkls -la /home/ahobacom/public_html/test.ahobao7.com/test-symlink# If you can see the contents, symlinks are allowed. Clean up after:rm /home/ahobacom/public_html/test.ahobao7.com/test-symlink

If the symlink resolves, you're good to go.

SSH Setup

If you followed Stephen's guide linked above, your server already has an id_ecdsa keypair and the public key is authorized with GitHub. You need two more things for the GitHub Actions workflow:

1. GitHub Actions needs to SSH into your server.

Generate a separate keypair for the CI runner (you can do this on your local machine):

ssh-keygen -t ed25519 -C "ahoba-deploy" -f ~/.ssh/ahoba_deploy

Add the public key to your server via cPanel → SSH Access → Manage SSH Keys → Import Key.

Then add both keys as GitHub repository secrets:

Secret Value
CPANEL_SSH_SECRETPrivate key contents
CPANEL_SSH_PUBLIC_KEYPublic key contents
CPANEL_HOSTYour domain e.g. ahobao7.com
CPANEL_USERcPanel username e.g. ahobacom
SERVER_IPServer IP address

One important note: this server runs SSH on port 428, not the default 22. Every ssh-keyscan, rsync, and appleboy/ssh-action call needs the port specified explicitly. This tripped up the workflow several times before it was caught.

The GitHub Actions Workflow

Here's the full workflow. It builds assets on the runner, rsyncs to a timestamped release folder, links shared files, flips the symlink, and runs Laravel's artisan commands — all in one push.

name: Deploy Laravel + Vue (cPanel)on: push: branches: - main
jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4
- name: Setup Node uses: actions/setup-node@v4 with: node-version: 20
- name: Install Node Dependencies run: npm install --force
- name: Build Assets run: npm run build
- name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: "8.2"
- name: Install Composer Dependencies run: composer install --no-dev --optimize-autoloader
- name: Setup SSH run: | mkdir -p ~/.ssh/ echo "${{ secrets.CPANEL_SSH_SECRET }}" > ~/.ssh/id_ecdsa echo "${{ secrets.CPANEL_SSH_PUBLIC_KEY }}" > ~/.ssh/id_ecdsa.pub chmod 600 ~/.ssh/id_ecdsa chmod 600 ~/.ssh/id_ecdsa.pub ssh-keyscan -H -p 428 ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
- name: Deploy Files to Server run: | RELEASE=$(date +%Y-%m-%d_%H-%M-%S) echo "RELEASE=$RELEASE" >> $GITHUB_ENV rsync -avz \ -e "ssh -i ~/.ssh/id_ecdsa -p 428 -o StrictHostKeyChecking=no" \ --exclude='.git' \ --exclude='.env' \ --exclude='node_modules' \ --exclude='tests' \ --exclude='.github' \ --exclude='storage' \ ./ \ ${{ secrets.CPANEL_USER }}@${{ secrets.SERVER_IP }}:/home/${{ secrets.CPANEL_USER }}/deployments/releases/$RELEASE/
- name: Activate Release & Run Laravel Commands uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.CPANEL_HOST }} username: ${{ secrets.CPANEL_USER }} key: ${{ secrets.CPANEL_SSH_SECRET }} port: 428 script: | BASE=/home/${{ secrets.CPANEL_USER }}/deployments RELEASE=${{ env.RELEASE }}
# Link shared .env and storage into the new release ln -sf $BASE/shared/.env $BASE/releases/$RELEASE/.env ln -sf $BASE/shared/storage $BASE/releases/$RELEASE/storage
# Flip the current symlink to the new release ln -sfn $BASE/releases/$RELEASE $BASE/current
# Run Laravel commands cd $BASE/current php artisan storage:link php artisan migrate --force php artisan config:cache php artisan route:cache php artisan view:cache
# Keep only the last 5 releases ls -dt $BASE/releases/*/ | tail -n +6 | xargs rm -rf

A few things worth explaining:

Why build on the runner instead of the server? Shared cPanel servers often have memory limits that make npm install unreliable or outright impossible. Building on the GitHub Actions runner means you get a clean, resource-rich environment every time, and you only ship the compiled output to the server — not node_modules.

What is $GITHUB_ENV? The RELEASE timestamp is generated in the rsync step but needs to be used later in the SSH step. Writing it to $GITHUB_ENV makes it available to all subsequent steps as ${{ env.RELEASE }}. Without this bridge, the two steps would generate different timestamps and the activation step wouldn't find the folder.

Why ln -sfn and not just ln -sf? When the target is a directory symlink, -sf alone can behave unexpectedly and create a symlink inside the target instead of replacing it. The -n flag tells ln to treat the destination as a regular file even if it's a symlink, which gives you a clean atomic swap every time.

How User Uploads Are Preserved

This is the part that makes the whole structure worth it.

In Laravel, uploaded files land in storage/app/public/ and are served through public/storage/ — which is itself a symlink created by php artisan storage:link. With the releases pattern, the chain looks like this:

Request: /storage/images/photo.jpg → public/storage/ (symlink via storage:link) → storage/app/public/ (symlink → shared/storage) → deployments/shared/storage/app/public/images/photo.jpg ✅

Every release points to the same shared storage/ folder. No matter how many deploys happen, the files are untouched. php artisan storage:link runs on every deploy because each new release directory needs its own public/storage symlink created fresh — it takes milliseconds and is completely harmless.

What a Deploy Looks Like

When you push to main, here's what actually happens:

  1. GitHub spins up an Ubuntu runner
  2. Node dependencies are installed and Vue assets are compiled
  3. Composer installs PHP dependencies without dev packages
  4. SSH is configured with your server's key and the host is added to known_hosts
  5. A timestamp is generated — e.g. 2026-03-27_15-45-00
  6. rsync copies only changed files into deployments/releases/2026-03-27_15-45-00/
  7. On the server, .env and storage/ are symlinked into the new release folder
  8. The current symlink flips to point at the new release — this is the atomic moment
  9. storage:link, migrate, and cache commands run
  10. Any releases beyond the last 5 are deleted

From a user's perspective, steps 1–7 are invisible. The switch at step 8 is instantaneous.

Troubleshooting: The "Index of /" Problem

After setting up the symlink and deploying, you might visit your domain and see a plain Apache directory listing — Index of / — instead of your Laravel app. This is what happened during this setup, and it's worth understanding why.

What causes it

The directory listing means Apache found the folder but no index.php in it. In this case, the document root was still pointing at the original /public_html/test.ahobao7.com folder — a real directory with nothing in it — rather than the Laravel public folder where index.php actually lives.

The failed approach — custom .htaccess rewrites

The first instinct was to drop a custom .htaccess into /public_html/test.ahobao7.com that rewrote requests to the deployment folder:

RewriteEngine onRewriteCond %{REQUEST_FILENAME} !-fRewriteCond %{REQUEST_FILENAME} !-dRewriteRule ^(.*)$ /home/ahobacom/deployments/current/public/$1 [L]

This doesn't work. Apache's RewriteRule rewrites URL paths, not filesystem paths. Passing an absolute server path like /home/ahobacom/deployments/... as the rewrite target just makes Apache try to serve that string as a URL — which fails silently and leaves you with the same directory listing.

The right solution is to not fight Apache with rewrites at all. Instead, replace the document root folder with a symlink that points directly to deployments/current/public:

# Remove the empty directory cPanel createdrm -rf /home/ahobacom/public_html/test.ahobao7.com
# Replace it with a symlink to your Laravel public folderln -s /home/ahobacom/deployments/current/public \ /home/ahobacom/public_html/test.ahobao7.com

Now when Apache looks for files at /public_html/test.ahobao7.com, it transparently follows the symlink into deployments/current/public — where index.php and Laravel's own .htaccess already live. No custom rewrite rules needed. No fighting the framework.

The symlink chain in full:

/public_html/test.ahobao7.com ← symlink (replaces the real folder) ↓/deployments/current/public ← symlink (managed by GitHub Actions) ↓/deployments/releases/2026-03-27_15-45-00/public ← real folder ✅

What about the .htaccess?

Laravel ships with a public/.htaccess that handles routing all requests through index.php. Since the document root now points into the public folder, Apache picks this file up automatically — no custom .htaccess needed anywhere else. Just make sure it's committed in your repo under public/.htaccess and it'll be included in every deploy.

If you still get a 403 after all this

A 403 usually means Apache found the symlink but isn't allowed to follow it. You need Options +FollowSymLinks enabled. Add it to the top of public/.htaccess:

Options +FollowSymLinks
<IfModule mod_rewrite.c> ...the rest of Laravel's .htaccess...</IfModule>

If your host has disabled AllowOverride for symlink options, you'll need to contact support to enable symlink following for your account.

Wrapping Up

The pattern here — timestamped releases, a current symlink, shared persistent files — isn't new. Capistrano has been doing this for years. But getting it running on cPanel with GitHub Actions, working around the port 428 quirk and the document root restriction, took some trial and error.

If you're starting from scratch, work through Stephen's guides first:

  1. Connect cPanel to GitHub — SSH keys, authorization, cloning private repos
  2. Asset Bundling on cPanel with GitHub Actions — building and deploying compiled assets

Then come back here when you're ready to take it further.

Share this writeup
Copy link
Share on X Share on LinkedIn

Join the conversation

Comments (0)

Comments are reviewed before they appear. Keep it useful and kind.

No comments yet. Add a thoughtful first response.

Leave a comment

Comment form loading…