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
- You push to your deploy branch (say main).
- GitHub reads the workflow file on that branch and spins up a runner.
- 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
- 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.
- .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. 🚀
Technical visionary and co-founder with deep expertise in software architecture and development.