Every Laravel deployment article eventually funnels you toward Forge, Vapor, or Kubernetes. For a portfolio site, a client project, or anything living on a VPS or shared hosting plan, I think that is the wrong default. My own site, mejba.me, has deployed itself to Hostinger on every push to main for as long as it has existed, using one GitHub Actions workflow: rsync over SSH plus a short post-deploy script. No deploy server, no agent, no monthly fee.
This post is that workflow, annotated line by line. Not a theoretical example: the excludes, the ordering, and the one rm -f in the middle of it each exist because something broke without them.

Why rsync over SSH still wins on a VPS or shared hosting
GitHub Actions gives you three things for free: a trigger (push to main), a clean Ubuntu runner, and encrypted secrets storage. Combine that with rsync and you get delta transfers (only changed files move), a deterministic file tree on the server, and a deploy you can read top to bottom in one screen.
The prerequisites are minimal:
- A Laravel project on GitHub
- SSH access to your server (Hostinger VPS and most shared plans include it)
- Composer available on the server
- PHP and your database already configured there
You do not need Node on the server. More on that below, because it changes how you handle assets.
Set up the SSH key and GitHub secrets
Generate a dedicated deploy key on your local machine. Do not reuse your personal key:
ssh-keygen -t rsa -b 4096 -f ~/.ssh/deploy_myproject -C "github-actions-deploy"
Add the public key (deploy_myproject.pub) to your host. On Hostinger: Advanced → SSH Access → SSH Keys → Add SSH Key.
Then add the private key and connection details as repository secrets under Settings → Secrets and variables → Actions:
| Secret | Example value |
|---|---|
HOSTINGER_SSH_HOST |
your server IP or hostname |
HOSTINGER_SSH_PORT |
65002 (Hostinger uses a non-standard port) |
HOSTINGER_SSH_USER |
your SSH username |
HOSTINGER_SSH_KEY |
the full private key contents |
DEPLOY_PATH |
/home/<user>/domains/example.com/public_html |
Nothing sensitive ever appears in the workflow file itself. The key exists for exactly one repository and one server, so rotating it later costs you two minutes.
The workflow file I actually run
This is the production workflow from my repo, with only the secrets referenced by name:
name: Deploy to Hostinger
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.HOSTINGER_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -p ${{ secrets.HOSTINGER_SSH_PORT }} -T 30 ${{ secrets.HOSTINGER_SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
echo "StrictHostKeyChecking accept-new" >> ~/.ssh/config
- name: Deploy via rsync
run: |
rsync -avz \
--exclude=".env" \
--exclude="vendor/" \
--exclude="storage/" \
--exclude="bootstrap/cache/" \
--exclude="node_modules/" \
--exclude="public/sitemap.xml" \
--exclude="public/image-sitemap.xml" \
--exclude="public/video-sitemap.xml" \
--delete-after \
-e "ssh -i ~/.ssh/id_rsa -p ${{ secrets.HOSTINGER_SSH_PORT }}" \
./ ${{ secrets.HOSTINGER_SSH_USER }}@${{ secrets.HOSTINGER_SSH_HOST }}:${{ secrets.DEPLOY_PATH }}
- name: Laravel post-deploy commands
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOSTINGER_SSH_HOST }}
username: ${{ secrets.HOSTINGER_SSH_USER }}
key: ${{ secrets.HOSTINGER_SSH_KEY }}
port: ${{ secrets.HOSTINGER_SSH_PORT }}
script: |
cd ${{ secrets.DEPLOY_PATH }}
php artisan down --retry=30 --refresh=10 || true
rm -f public/hot
mkdir -p storage/framework/{cache,sessions,views} storage/logs
chmod -R 775 storage bootstrap/cache
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
rm -rf public/storage
ln -s ../storage/app/public public/storage
php artisan migrate --force
php artisan optimize:clear
php artisan optimize
php artisan generate:sitemap || true
php artisan up
Push to main, watch the Actions tab, and the site updates itself. That is the whole ceremony.
The exclude list is where the scars are
Every --exclude line in that rsync command is a lesson:
.envis obvious. Your production credentials live on the server, never in the repo.storage/holds uploaded media, sessions, and logs. Because I run rsync with--delete-after, forgetting this exclude would delete every user upload on the next deploy. This is the single most dangerous line to get wrong.vendor/andbootstrap/cache/are rebuilt on the server by Composer and artisan. Shipping them from the runner wastes transfer time and can smuggle in wrong-PHP-version artifacts.- The three sitemap XML files are the non-obvious one. My app regenerates
sitemap.xml,image-sitemap.xml, andvideo-sitemap.xmlon the server every five minutes via the scheduler. Those files exist only in production, so a mirroring rsync with--deletewould wipe them on every deploy and Google would catch the site mid-gap. If your app writes any file intopublic/at runtime, it needs an exclude.
Also note --delete-after rather than plain --delete. Deletions happen after all new files have transferred, which shrinks the window where the live site references a file that no longer exists.
The post-deploy script, in the order that matters
The ordering is deliberate:
php artisan downfirst. Visitors get a clean 503 with retry headers instead of a half-updated page while Composer churns. The|| truemeans a first-ever deploy (when the app cannot boot yet) does not kill the pipeline.rm -f public/hotimmediately after. If a Vite dev-serverhotfile ever reaches production, every asset URL on the site points atlocalhost:5173and the whole front end goes blank. I got burned once, then made the pipeline delete it unconditionally on every single deploy. The full story is in my write-up of the Laravel Vite production asset fix.- Composer inside the maintenance window. It is the slowest step, so it runs while the site is already down anyway.
- Storage symlink rebuilt manually.
rm -rf public/storage && ln -sis more reliable on shared hosting thanartisan storage:link, which can fail quietly when the doc root is itself a symlink. migrate --force, thenoptimize:clearbeforeoptimize. Clear stale caches first, rebuild config/route/view caches second. If you cache before clearing you can pin old config in place.php artisan uplast. Total maintenance window on my deploys is usually under a minute.
One host-specific note: some shared PHP builds are missing extensions your lock file expects. On my Hostinger plan the CLI PHP lacked ext-sodium, so my Composer line carries --ignore-platform-req=ext-sodium. Check php -m on your server before you assume the runner and the server agree.
Assets: build locally, commit public/build
There is no npm on my production server, and I do not want the deploy to depend on a 90-second npm ci anyway. So the policy, documented right inside my .gitignore, is:
/public/hot
# /public/build is committed because we build locally (no npm on production)
Run npm run build before you commit, ship the hashed assets through git, and let rsync carry them over. The trade-off is a slightly noisier diff; the win is that a deploy can never fail on a JavaScript dependency. If you would rather keep public/build out of the repo, add a Node build step to the workflow and rsync the artifacts instead. Either is fine. What is not fine is committing public/hot, which is why it sits in .gitignore one line above.
Where this pipeline stops being enough
I run this exact setup across my own projects and it covers the honest majority of Laravel deployments. You have outgrown it when:
- You run queue workers. Add
php artisan queue:restart(or restart Horizon under Supervisor) to the script, otherwise workers keep executing old code. My Supervisor and Redis queues setup covers that layer. - You need zero-downtime releases. Symlinked release directories or a tool built around them beat a maintenance window once traffic is constant.
- You are on AWS with autoscaling. At that point reach for a pipeline built for instances, like the CodeDeploy CI/CD setup on EC2.
- The server itself is the bottleneck. Before scaling hardware, it is worth working through my checklist for optimizing Laravel on shared hosting.
One warning that comes with automation: once main auto-deploys, main is production. Protect the branch, keep experiments on feature branches, and never let a tool or an AI agent push to main casually. The pipeline will faithfully ship whatever lands there, including your mistakes, in about ninety seconds.
Ship it once, then stop thinking about it
The best thing I can say about this workflow is that I forget it exists. Content changes, fixes, whole feature branches merge, and the site updates itself while I do something else. The workflow above is copy-paste ready for your own Laravel app; the excludes are the only part you have to adapt to what your app writes at runtime.
Nothing here needs me. Hardening it for a server that already carries live traffic is one of the things I set up for clients, and my services page covers what that includes.