Skip to main content
Laravel Applications

How to Deploy Laravel Projects to Production Automatically Using GitHub Actions

Set up a GitHub Actions pipeline that deploys Laravel to Hostinger over SSH and rsync on every push. The exact workflow file I run in production.

8 min
Read time
1,486
Words
Published
Last revised
Engr Mejba Ahmed

Written by

Engr Mejba Ahmed

Share Article

How to Deploy Laravel Projects to Production Automatically Using GitHub Actions

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.

How to Deploy Laravel Projects to Production Automatically Using GitHub Actions - overview of why rsync over ssh still wins on a vps or shared hosting, set up the ssh key and github secrets

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:

  1. A Laravel project on GitHub
  2. SSH access to your server (Hostinger VPS and most shared plans include it)
  3. Composer available on the server
  4. 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:

  • .env is 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/ and bootstrap/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, and video-sitemap.xml on the server every five minutes via the scheduler. Those files exist only in production, so a mirroring rsync with --delete would wipe them on every deploy and Google would catch the site mid-gap. If your app writes any file into public/ 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:

  1. php artisan down first. Visitors get a clean 503 with retry headers instead of a half-updated page while Composer churns. The || true means a first-ever deploy (when the app cannot boot yet) does not kill the pipeline.
  2. rm -f public/hot immediately after. If a Vite dev-server hot file ever reaches production, every asset URL on the site points at localhost:5173 and 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.
  3. Composer inside the maintenance window. It is the slowest step, so it runs while the site is already down anyway.
  4. Storage symlink rebuilt manually. rm -rf public/storage && ln -s is more reliable on shared hosting than artisan storage:link, which can fail quietly when the doc root is itself a symlink.
  5. migrate --force, then optimize:clear before optimize. Clear stale caches first, rebuild config/route/view caches second. If you cache before clearing you can pin old config in place.
  6. php artisan up last. 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.

Advertisement
Coffee cup

Enjoyed this article?

Your support helps me create more in-depth technical content, open-source tools, and free resources for the developer community.

Related Topics

Engr Mejba Ahmed

Engr Mejba Ahmed

Engr. Mejba Ahmed builds AI-powered applications and secure cloud systems for businesses worldwide. With 8+ years shipping production software in Laravel, Python, and AWS, he's helped companies automate workflows, reduce infrastructure costs, and scale without security headaches. He writes about practical AI integration, cloud architecture, and developer productivity.

Related Articles

Browse All

Comments

Leave a Comment

Comments are moderated before appearing.

Learning Resources

Expand Your Knowledge

Accelerate your growth with structured courses, verified certificates, interactive flashcards, and production-ready AI agent skills.

Sample Certificate of Completion

Sample certificate — complete any course to earn yours

Engr Mejba Ahmed

Engr Mejba Ahmed

AI assistant · trained on my work

👋

Hey there!

Quick Actions

WhatsApp Direct line to me

Chat on WhatsApp

+880 1723 741224 · Replies within the hour on working days

Popular Questions

Engr Mejba Ahmed is connected
Engr Mejba Ahmed is typing...
Engr Mejba Ahmed avatar

✉ Want me to follow up? Drop your email

Engr Mejba Ahmed avatar

📞 Connect Directly

Choose how you'd like to reach me

WhatsApp

+880 1723 741224

Email

mejba.13@gmail.com

✓ Details sent! I'll get back to you shortly.

Powered by OpenAI

335+

Blog Posts

25

AI Courses

63

Projects

Services & Expertise

Pricing & Process

Learning & Resources

Connect & Support