A deployment should leave the previous release available until the replacement is ready. I build each version in its own folder, test it, then switch the current symlink. The useful details are how shared files, failed checks and PHP’s code cache behave around that switch.
The layout
/srv/example.com/
├── current -> releases/20261002-104512
├── releases/
│ ├── 20261001-183020/
│ └── 20261002-104512/
├── shared/
│ └── uploads/ (linked into every release)
└── repo/ (the clone releases are checked out from)The web server’s document root is current. Uploads, and anything else that must survive a deploy, live in shared/ and are linked into each release.
Detail one: ln -sfn is not atomic
Most deploy scripts switch with ln -sfn. Under the hood that removes the old link, then creates the new one. For a moment there is no current at all. On a quiet site you never notice. On a busy one, requests that land in that gap fail with errors such as “too many levels of symbolic links”. Stage the new link beside the old one and rename it over the top. A rename in the same directory is a single atomic step:
ln -s "$release" "$app/current.next"
mv -T "$app/current.next" "$app/current"Detail two: roll back on your own
Everything that has to be true before visitors arrive should run before the switch. Anything that runs after it, such as cache warming or migrations, needs a way back. Here is the shape of a deploy script that does both:
#!/usr/bin/env bash
set -euo pipefail
umask 022
app=/srv/example.com
release="$app/releases/$(date -u +%Y%m%d-%H%M%S)"
previous=$(readlink -f "$app/current" || true)
git -C "$app/repo" fetch --quiet origin
git -C "$app/repo" worktree add --detach "$release" origin/main
ln -s "$app/shared/uploads" "$release/uploads"
# Checks that must pass before anyone sees this release.
find "$release" -name '*.php' -not -path '*/vendor/*' -print0 | xargs -0 -n1 -P4 php -l > /dev/null
ln -s "$release" "$app/current.next"
mv -T "$app/current.next" "$app/current"
if ! "$release/bin/after-deploy"; then
echo "After-deploy step failed, switching back to $previous" >&2
ln -s "$previous" "$app/current.next"
mv -T "$app/current.next" "$app/current"
exit 1
fiAutomatic rollback has a useful side effect: it surfaces steps that have been failing quietly. A cache warmer missing a permission, for example, now stops the deploy instead of failing unnoticed every time. That is a good thing.
Detail three: OPcache will not notice
On a production PHP server you want opcache.validate_timestamps=0, so PHP never checks the disk for changed files. That also means it will not notice your new release. Reset the cache after the switch, and do it in that order: files first, reset second. Reset before the copy and the old code keeps running.
# Either reload PHP-FPM gracefully...
sudo systemctl reload php8.4-fpm
# ...or reset just one pool's cache with cachetool
cachetool opcache:reset --fcgi=127.0.0.1:9000Detail four: watch your permissions
Code should be readable by the web server and writable by nobody but the deploy user. umask 022 in the deploy script covers the usual case, but default ACLs on the releases folder can quietly override it and make every new file group-writable:
getfacl /srv/example.com/releases | grep default
# If you see default:group::rwx, bring it back to read-only
sudo setfacl -m g::r-x,m::r-x,d:g::r-x,d:m::r-x /srv/example.com/releasesDetail five: tidy up properly
Keep the last few releases for instant rollback, and remove older ones through Git so it forgets the worktrees too:
cd /srv/example.com
current=$(readlink -f current)
ls -1dt releases/*/ | tail -n +6 | while read -r old; do
[ "$(readlink -f "$old")" = "$current" ] && continue
git -C repo worktree remove --force "$old"
doneKeep secrets out of all of this. Configuration files with passwords belong in a private folder that the deploy copies in, never in the repository.