Workflow File
Create .github/workflows/deploy-production.yml for the deployment job. Generic copies of all workflow files are in the attachments/ folder.
deploy-production.yml
Triggers on push to main and deploys changed files via FTP, then runs composer install on the server via SSH.
name: Deploy to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
environment: production.sitename
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Deploy changed files via FTP
uses: SamKirkland/FTP-Deploy-Action@v4.3.6
with:
server: ${{ secrets.FTP_SERVER }}
username: ${{ secrets.FTP_USER }}
password: ${{ secrets.FTP_PASS }}
port: ${{ secrets.FTP_PORT }}
protocol: ftp
local-dir: ./
server-dir: /public_html/
state-name: .ftp-deploy-sync-state.json
exclude: |
.git/**
.github/**
node_modules/**
vendor/**
.env*
Excluding files
The exclude list tells FTP Deploy which files to skip. At minimum exclude:
| Pattern | Reason |
|---|---|
.git/**, .github/** |
Version control — not needed on server |
node_modules/**, vendor/** |
Install on server via SSH instead |
.env* |
Keep .env on server, never deploy it |
Common additions for Laravel:
exclude: |
tests/**
storage/logs/**
storage/framework/cache/**
storage/framework/sessions/**
storage/framework/testing/**
storage/framework/views/**
*.log
phpunit.xml
package*.json
*.md
**/*.md
Tip — The more you exclude, the faster incremental deploys run. Exclude anything that's installed, generated, or environment-specific on the server.
FTP sync state
FTP-Deploy-Action keeps a file called .ftp-deploy-sync-state.json on the server to track which files have been uploaded. On the next deploy it diffs against this state and only uploads what changed — making subsequent deploys much faster.
- The
state-nameoption controls the filename on the server - It's stored in
server-dir(i.e./public_html/) - Don't delete it — removing it forces a full re-upload on the next run
- Add it to
.gitignoreso it's never committed locally - If you need to force a full re-deploy (e.g. after a server wipe), delete the state file from the server via FTP/SSH, then push again
# Force full re-deploy by removing the state file via SSH
rm /home/uXXXXXXXXX/domains/yourapp.com/public_html/.ftp-deploy-sync-state.json
- name: Composer install (no-dev, optimized)
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd ${{ secrets.FTP_SERVER_DIR }}
/opt/alt/php85/usr/bin/php /usr/local/bin/composer2 install --no-dev --no-interaction --optimize-autoloader
> **Note** — The PHP path `/opt/alt/php85/usr/bin/php` and composer binary `/usr/local/bin/composer2` are correct for **PHP 8.5 on Hostinger shared hosting** (tested with Laravel 13). If you're on a different PHP version, adjust accordingly. Use the `test-ssh.yml` workflow to confirm the correct paths on your plan.
## Test workflows
Run these manually from **GitHub → Actions → Run workflow** before pushing to `main`:
- `test-ssh.yml` — verifies SSH connection, prints PHP/composer paths, checks server directory
- `test-ftp.yml` — dry-run FTP deploy (no files uploaded, just validates credentials and lists changes)
- `test-node.yml` — installs npm deps and runs a Vite build to verify the frontend compiles
## Push and verify
```bash
git push origin main