You don’t need a full platform like Envoyer or Forge to get reliable PHP deployments. A small GitHub Actions workflow plus SSH and rsync is enough for most projects.
Prerequisites
- PHP 8.1+ app with
composer.json - Linux server with SSH access
- Nginx + PHP-FPM installed and working
- A non-root deploy user with access to the web root
On the server, create a deploy user and base directories:
sudo adduser deploy
sudo mkdir -p /var/www/myapp/{releases,shared}
sudo chown -R deploy:www-data /var/www/myapp
sudo chmod -R g+w /var/www/myappTypical layout:
/var/www/myapp/current: symlink to the latest release/var/www/myapp/releases/20251105123000: code for a specific deployment/var/www/myapp/shared: persistent storage (.env, storage etc)
Nginx configuration for the release symlink
Point Nginx at the current/public directory so deployment are just symlink switches:
server {
server_name example.com;
root /var/www/myapp/current/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
}
access_log /var/log/nginx/myapp.access.log;
error_log /var/log/nginx/myapp.error.log;
}Reload Nginx:
sudo systemctl reload nginxGenerating an SSH key for GitHub Actions:
ssh-keygen -t ed25519 -C "github-actions@myapp" -f ./myapp_deploy_keyCopy the public key to the server:
ssh-copy-id -i ./myapp_deploy_key.pub deploy@your-serverAdd the private key (myapp_deploy_key) to your GitHub repo secrets as SSH_PRIVATE_KEY.
You’ll also want:
DEPLOY_HOST: your serverDEPLOY_USER: deployDEPLOY_PATH: /var/www/myapp
GitHub Actions workflow file
Create a file at .github/workflows/deploy.yml:
name: Deploy PHP app
on:
push:
branches: [ "main" ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, intl, pdo_mysql
coverage: none
- name: Install Composer dependencies
run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
- name: Run tests
run: |
php vendor/bin/phpunit --testsuite=Unit
- name: Prepare artifact
run: |
mkdir -p artifact
rsync -a \
--exclude=.git \
--exclude=node_modules \
--exclude=tests \
./ artifact/
- name: Add SSH key
uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy via rsync
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
RELEASE=$(date +"%Y%m%d%H%M%S")
RSYNC_TARGET="$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/releases/$RELEASE"
rsync -az --delete \
artifact/ \
"$RSYNC_TARGET"
ssh $DEPLOY_USER@$DEPLOY_HOST bash << 'EOF'
set -e
RELEASE_DIR="$DEPLOY_PATH/releases/$RELEASE"
CURRENT_LINK="$DEPLOY_PATH/current"
SHARED_DIR="$DEPLOY_PATH/shared"
mkdir -p "$SHARED_DIR/storage" "$SHARED_DIR/bootstrap/cache"
# Link shared directories and files
rm -rf "$RELEASE_DIR/storage"
ln -s "$SHARED_DIR/storage" "$RELEASE_DIR/storage"
if [ -f "$SHARED_DIR/.env" ]; then
ln -sf "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
fi
cd "$RELEASE_DIR"
php artisan config:cache || true
php artisan route:cache || true
php artisan view:cache || true
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
EOFThis uses the community setup-php action for a clean PHP 8.x environment.
To add front-end builds, insert npm ci / rpm run build before the artefact step.
Common mistakes and fixes
| Mistake | Fix |
|---|---|
| Typing to run Composer on the server during every deployment | Run composer in CI, deploy the vendor directory, and keep your server lean |
Pointing Nginx at /var/www/myapp/releases/... | Always point at /var/www/myapp/current/public and switch the symlink atomically |
| Using root for deployments | Use a dedicated deploy user with minimal privileges and strict SSH keys only |
Once it’s wired, every push to main gives you a tested, repeatable, near-zero-downtime deployment with one YAML file.
Leave a Reply