Skip to content
DevOps

Deploy Laravel with GitHub Actions: SSH and FTP Pipelines

Ahmed Zobayer
Ahmed Zobayer
Co-Founder & Chief Technology Officer (CTO)
Jul 29, 2026
Deploy Laravel with GitHub Actions: SSH and FTP Pipelines

Deploy Laravel with GitHub Actions: SSH and FTP Pipelines

Manually deploying a Laravel app — SSH in, git pull, run migrations, clear caches, hope you didn't forget a step — is fine right up until the day you forget a step. The fix is a CI/CD pipeline: push your code, and a robot does the rest, the same way every time.

This guide shows two complete GitHub Actions pipelines that deploy on every push:

  • SSH deploy — the runner connects to your server and runs the deploy commands there. Fast, atomic, and can run migrations and clear caches. Use this whenever you have shell access.
  • FTP deploy — the runner builds your app and uploads the files over FTP/FTPS. The fallback for cheap shared hosting with no SSH.

By the end you'll have a git push that ships to production hands-free.

How a deploy pipeline works

  1. You push to your deploy branch (say main).
  2. GitHub reads the workflow file on that branch and spins up a runner.
  3. The runner connects to your server and either runs your deploy script (SSH) or uploads the built files (FTP).
Gotcha #1: A workflow only runs if its file exists on the branch you push. If you change the trigger branch, the updated workflow file must be committed onto that same branch — otherwise nothing fires.

Prerequisites

  • A GitHub repo with Actions enabled (Settings → Actions → General).
  • Production hosting with either SSH access + Git installed (for the SSH pipeline) or FTP/FTPS credentials (for the FTP pipeline).
  • Your secrets stored under Settings → Secrets and variables → Actions — never hard-code credentials in the workflow file.

Option A — SSH deploy (recommended)

With SSH, the heavy lifting happens on the server. Create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

# A newer push cancels an in-flight deploy, so the server always
# lands on the latest commit rather than an intermediate one.
concurrency:
  group: production-deploy
  cancel-in-progress: true

jobs:
  deploy:
    name: SSH deploy
    runs-on: ubuntu-latest
    steps:
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT || 22 }}
          script_stop: true      # fail the job on the first command that errors
          script: |
            set -e
            cd "${{ secrets.DEPLOY_PATH }}"

            # Maintenance mode while we swap code and migrate.
            php artisan down --render="errors::503" || true

            # Match the branch exactly. reset --hard (not pull) avoids
            # merge conflicts if the server's working tree ever drifts.
            git fetch --all
            git reset --hard origin/main

            # Production dependencies + optimized autoloader.
            composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist

            # Apply migrations non-interactively.
            php artisan migrate --force

            # Rebuild caches for production speed.
            php artisan optimize:clear
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache

            # Pick up new code in queue workers, then go back online.
            php artisan queue:restart
            php artisan up

What each part does

  • concurrency cancels an older run when a newer push arrives, so two deploys never race.
  • git reset --hard origin/main guarantees the server matches the branch byte-for-byte. Safer than git pull, which fails with merge conflicts if anything on the server changed — a common headache if you commit compiled public/build assets.
  • --force on migrate skips the "are you sure?" prompt that would otherwise hang CI forever.
  • down / up wrap the risky window in maintenance mode. The || true keeps a first-ever deploy from failing when the app isn't down yet.
Building assets: if you don't commit public/build, add npm ci and npm run build before the cache step. Running it on the server keeps the workflow simple; running it on the runner keeps Node off your server.

Secrets for the SSH pipeline

SecretExampleWhere it comes from

SSH_HOST | 203.0.113.10 | Your host's SSH panel (IP or hostname)

SSH_USER | deploy | Your SSH username

SSH_PORT | 22 | SSH port (some shared hosts use a custom one)

SSH_PRIVATE_KEY | -----BEGIN OPENSSH KEY----- | The private key you generate (below)

DEPLOY_PATH | /var/www/app | Run cd yourapp && pwd on the server

Generating a deploy key

Run this locally (Git Bash on Windows, or any terminal on macOS/Linux):

ssh-keygen -t ed25519 -C "github-deploy" -f ./deploy_key

It produces two files:

FileTypeGoes todeploy_key | private | GitHub secret SSH_PRIVATE_KEY

deploy_key.pub | public | The server's ~/.ssh/authorized_keys

The rule that trips everyone up: the long -----BEGIN...----- block is the private key and goes to GitHub; the single ssh-ed25519 AAAA... line is the public key and goes to the server. Public → put on the server.

Install the public key on the server:

cat ./deploy_key.pub   # copy this one line
# then on the server:
echo "ssh-ed25519 AAAA... github-deploy" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Verify it works before trusting CI — this must log in with no password prompt:

ssh -i ./deploy_key -p 22 deploy@203.0.113.10 "cd /var/www/app && git status"
Gotcha #2: The CI key must have no passphrase — the runner can't type one. If yours has one, make a dedicated passphrase-free deploy key, or pass passphrase: ${{ secrets.SSH_PASSPHRASE }} to the action.
Gotcha #3: The app must already be a git clone on the server. If it was uploaded by FTP, initialise it once with git init, git remote add origin ..., git fetch origin, and git checkout -f main.

Option B — FTP deploy (no SSH required)

Some budget shared hosts give you FTP and nothing else. Here the runner builds the app on GitHub's machine and syncs the result up. Only changed files are uploaded — the action keeps a state file on the server.

Create .github/workflows/deploy-ftp.yml:

name: FTP Deploy

on:
  push:
    branches: [main]

jobs:
  ftp-deploy:
    name: Build and upload
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
      - name: Composer install
        run: composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      - name: Build assets
        run: |
          npm ci
          npm run build

      - name: Upload over FTPS
        uses: SamKirkland/FTP-Deploy-Action@v4.3.5
        with:
          server: ${{ secrets.FTP_HOST }}
          username: ${{ secrets.FTP_USER }}
          password: ${{ secrets.FTP_PASSWORD }}
          port: ${{ secrets.FTP_PORT }}         # 21 for FTP, 990 for implicit FTPS
          protocol: ftps                          # prefer ftps; use ftp only if unsupported
          local-dir: ./
          server-dir: ${{ secrets.FTP_REMOTE_DIR }}   # e.g. /public_html/ (trailing slash required)
          exclude: |
            **/.git*
            **/.git*/**
            **/node_modules/**
            **/tests/**
            .env
            .github/**

Secrets for the FTP pipeline

SecretExampleNotes

FTP_HOST | ftp.example.com | FTP server host

FTP_USER | deploy@example | FTP account username

FTP_PASSWORD | •••••••• | FTP account password

FTP_PORT | 21 | 21 = FTP, 990 = implicit FTPS

FTP_REMOTE_DIR | /public_html/ | Target directory (trailing slash needed)

The two big FTP caveats

  1. FTP can't run artisan. No migrations, no cache clears, no queue restarts. Run migrations another way — a scheduled cron on the host (php artisan migrate --force), or a temporary admin-only route you remove afterward.
  2. .env is excluded on purpose. The server keeps its own production .env; uploading your local one would overwrite live credentials. And on hosts where only public/ is web-facing, the Laravel folder layout makes FTP deploys awkward — another reason to prefer SSH.

SSH vs FTP at a glance

CapabilitySSHFTP

Runs composer install on host | ✅ | ❌ (built on runner)

Runs artisan migrate | ✅ | ❌ (manual/cron)

Clears & rebuilds caches | ✅ | ❌

Restarts queue workers | ✅ | ❌

Maintenance mode | ✅ | ❌

Works without shell access | ❌ | ✅

Encrypted transfer | ✅ | ⚠️ only with FTPS

Speed on large changes | ✅ (git delta) | ⚠️ (file sync)

Troubleshooting

dial tcp ...: i/o timeout (SSH) — the runner never reached the SSH port. Check, in order: wrong port (some shared hosts use a non-standard one), wrong host (use the raw IP, not a domain that resolves to a proxy), or a firewall/IP allow-list blocking GitHub's large runner IP range. Isolate it by running the same ssh -v command from your own machine.

handshake failed / unable to authenticate (SSH) — the key was rejected. Usually the public key isn't in authorized_keys, or you pasted the public key into SSH_PRIVATE_KEY by mistake (it must be the BEGIN ... PRIVATE KEY block), or the key has a passphrase.

composer: command not found (SSH) — the SSH action uses a non-login shell, so .bashrc PATH entries may not load. Use absolute paths (/usr/bin/php, /usr/local/bin/composer); find them with which php composer.

Workflow didn't trigger — the workflow file must be on the branch you pushed, with a matching on.push.branches.

FTP uploads but the site 500s — the server's .env is missing, migrations haven't run (FTP can't run them), or caches are stale (delete bootstrap/cache/*.php via FTP as a manual workaround).

Wrapping up

Once this is in place, deployment stops being a checklist you can get wrong and becomes a side effect of merging code. Start with SSH if you can — it's faster, safer, and handles migrations and caches for you. Reach for FTP only when your host leaves you no choice.

Ship it. 🚀

Ahmed Zobayer
Ahmed Zobayer
Co-Founder & Chief Technology Officer (CTO), Codevioso

Technical visionary and co-founder with deep expertise in software architecture and development.