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

Lorenzo ·

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:How To Connect cPanel To GitHub and Deploy Private Repositories — start here if you haven't connected your cPanel server to GitHub yet. It walks you through generating the SSH keypair on the server and authorizing it with GitHub.Asset Bundling For Laravel Applications On cPanel Using GitHub Actions — covers building your Vue/Node assets on the CI runner and rsyncing the compiled output to the server.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 DeploymentsThe 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 StructureBefore 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 touchedEvery 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/.envcp -r /home/ahobacom/public_html/test.ahobao7.com/AHOBAO7-portfolio/storage \ /home/ahobacom/deployments/shared/The cPanel Problem — Why a Symlink Was NecessaryMy 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.comcPanel 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-symlinkIf the symlink resolves, you're good to go.SSH SetupIf 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_deployAdd 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 contentsCPANEL_SSH_PUBLIC_KEYPublic key contentsCPANEL_HOSTYour domain e.g. ahobao7.comCPANEL_USERcPanel username e.g. ahobacomSERVER_IPServer IP addressOne 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 WorkflowHere'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: - mainjobs: 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 -rfA 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 PreservedThis 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 LikeWhen you push to main, here's what actually happens:GitHub spins up an Ubuntu runnerNode dependencies are installed and Vue assets are compiledComposer installs PHP dependencies without dev packagesSSH is configured with your server's key and the host is added to known_hostsA timestamp is generated — e.g. 2026-03-27_15-45-00rsync copies only changed files into deployments/releases/2026-03-27_15-45-00/On the server, .env and storage/ are symlinked into the new release folderThe current symlink flips to point at the new release — this is the atomic momentstorage:link, migrate, and cache commands runAny releases beyond the last 5 are deletedFrom a user's perspective, steps 1–7 are invisible. The switch at step 8 is instantaneous.Troubleshooting: The "Index of /" ProblemAfter 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 itThe 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 rewritesThe 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 correct fix — symlink the document rootThe 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.comNow 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 thisA 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 UpThe 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:Connect cPanel to GitHub — SSH keys, authorization, cloning private reposAsset Bundling on cPanel with GitHub Actions — building and deploying compiled assetsThen come back here when you're ready to take it further.