Deploy PHP with Git and a Shared Storage Dir
Ship PHP with git releases on the VPS: clone to timestamped folders, symlink current, and keep .env plus storage in a shared directory that deploys never wipe.
Netbay Engineering
Netbay Engineering
On this page
git pull into the live web root works until it does not. A half-updated vendor, a missing .env, and a wiped storage/uploads directory are the usual scars. The fix used by Capistrano, Deployer, and every boring shell script is the same: each release is a new directory, current is a symlink, and mutable files live in a shared directory that deploys never delete.
This is that layout on Ubuntu 24.04 for Laravel or any PHP app with a public/ root. WordPress can use the same pattern if uploads live under shared/. You need a deploy user, a bare or remote git URL, and PHP-FPM already serving /var/www/app/current/public.
Directory layout
Create the skeleton once as the deploy user:
sudo mkdir -p /var/www/app/releases /var/www/app/shared/storage /var/www/app/shared/bootstrap-cache
sudo chown -R deploy:www-data /var/www/app
sudo chmod 750 /var/www/app
install -m 640 -o deploy -g www-data /dev/null /var/www/app/shared/.envPut production secrets in shared/.env by hand or from a secrets store. Never commit it. shared/storage is the Laravel storage tree (or WordPress uploads). shared/bootstrap-cache holds compiled config if you persist it; many deploys rebuild cache each release instead.
nginx root is /var/www/app/current/public. current does not exist until the first release symlink. Keep a maintenance vhost ready for that first hour.
A release script without magic
A release name is a UTC timestamp. Clone or archive from git into releases/$STAMP, install Composer with --no-dev, link shared paths, cache config, then flip the symlink atomically with ln -sfn. Reloading FPM last makes opcache pick up new files when validate_timestamps is off.
#!/bin/bash
set -euo pipefail
APP=/var/www/app
STAMP=$(date -u +%Y%m%dT%H%M%S)
REL=$APP/releases/$STAMP
git clone --depth 1 --branch "$1" git@github.com:example/app.git "$REL"
cd "$REL"
php -d memory_limit=512M /usr/local/bin/composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --classmap-authoritative
ln -sfn $APP/shared/.env $REL/.env
rm -rf $REL/storage
ln -sfn $APP/shared/storage $REL/storage
ln -sfn $APP/shared/storage/app/public $REL/public/storage
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
ln -sfn $REL $APP/current
sudo systemctl reload php8.3-fpm
sudo systemctl restart laravel-worker
# keep five releases
ls -1dt $APP/releases/* | tail -n +6 | xargs -r rm -rfPass the git ref as $1 (a tag, not main by habit). --depth 1 keeps High-Speed SSD clean. rm -rf $REL/storage before linking is required because Laravel ships an empty storage tree that would hide the shared one.
ln -sfn is atomic for the symlink. nginx keeps serving the old directory until the next request looks up current. In-flight PHP workers may still run old code until FPM reload; that is expected. Queue workers must be restarted because they do not notice the symlink.
Use $STAMP and $REL without braces. Do not interpolate secrets into the script; they live in shared/.env.
Shared directory rules
shared/.env mode 640, deploy:www-data. shared/storage 2775 as in the permissions post. Deploys must never rsync --delete across shared/. A failed clone leaves the previous current symlink in place; that is the rollback: ln -sfn /var/www/app/releases/OLDESTAMP /var/www/app/current && reload FPM.
WordPress variant: clone core+themes into the release, link wp-content/uploads to shared/uploads, and keep wp-config.php in shared/ or generate it. Do not keep wp-config in git with production salts.
Composer vendor lives in the release, not in shared, unless builds are too slow and you have a carefully invalidated cache. Shared vendor plus a new lock file is a class of bugs you do not need. On a 2 GB Xeon Platinum box a --prefer-dist install is usually under a minute.
What git should not contain
.env, storage/logs, storage/framework/cache, node_modules, vendor, .phpunit.result.cache. If someone committed those, add them to gitignore and git rm --cached. Deploy keys on the VPS are read-only, restricted to the app repo, and not reused as your personal key.
Keep nginx document root on current/public, not on a releases path. Hard-coding a timestamp in the vhost is how you serve an old release after a flip.
After the flip, hit /health or a known route before you delete old releases. If migrate --force fails, the script exits before ln -sfn and current is unchanged. That is the point of set -e.
Takeaway
A PHP deploy is a new directory, a shared .env and storage tree, an atomic current symlink, and an FPM reload. git pull in place is not a release process. On a Lucknow VPS this script is the whole CD story until you outgrow it.
You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and run this git release layout - netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance