· 5 min read
Atomic symlink deploys, step by step
A releases/ directory, a current symlink, and shared/ for things that survive across releases. The only tricky part is permissions on the shared uploads folder.
By Marten
rsync checks modification time and file size by default. It does not check content. This is why rsync is fast, and also why it is the wrong tool for the last step of a deploy.
You do not want a deploy that leaves your app in a half-written state because rsync was interrupted. You want a deploy where the moment of “this is the new version” is a single kernel call that either succeeds entirely or does not happen at all. That kernel call is rename(2), invoked via mv -T on a symlink.
I’ve been running this exact pattern for seven years across a dozen projects. Here is the version I use now, with the parts nobody tells you about at the end.
The skeleton
The directory layout on the server:
/srv/www/example.com/
├── current -> releases/20260415-093301
├── releases/
│ ├── 20260410-212015
│ ├── 20260412-084433
│ └── 20260415-093301
└── shared/
├── .env
├── storage/
│ └── logs/
└── uploads/
One-time setup, run once on the server:
mkdir -p /srv/www/example.com/{releases,shared/storage/logs,shared/uploads}
touch /srv/www/example.com/shared/.env
chown -R www-data:www-data /srv/www/example.com/shared
chmod -R 2775 /srv/www/example.com/shared
The 2775 permission is important. The 2 is the setgid bit. Files created inside this tree inherit the group of the parent directory, which means a deploy running as your user cannot create uploads that the web server user cannot read.
The deploy script
I keep it in /usr/local/bin/deploy-example on the server:
#!/usr/bin/env bash
set -euo pipefail
APP_ROOT=/srv/www/example.com
BUILD_DIR=/tmp/example-build
RELEASE=$(date +%Y%m%d-%H%M%S)
RELEASE_DIR="$APP_ROOT/releases/$RELEASE"
# 1. Copy the build into a new release directory
mkdir -p "$RELEASE_DIR"
rsync -a --delete "$BUILD_DIR/" "$RELEASE_DIR/"
# 2. Symlink shared state into the release
ln -sf "$APP_ROOT/shared/.env" "$RELEASE_DIR/.env"
rm -rf "$RELEASE_DIR/storage"
ln -sf "$APP_ROOT/shared/storage" "$RELEASE_DIR/storage"
rm -rf "$RELEASE_DIR/public/uploads"
ln -sf "$APP_ROOT/shared/uploads" "$RELEASE_DIR/public/uploads"
# 3. Run migrations against the new code
cd "$RELEASE_DIR"
./artisan migrate --force
# 4. Atomic symlink swap
ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.new"
mv -T "$APP_ROOT/current.new" "$APP_ROOT/current"
# 5. Reload web server
systemctl reload nginx
systemctl reload php8.3-fpm
# 6. Keep the last 5 releases, delete the rest
cd "$APP_ROOT/releases"
ls -1t | tail -n +6 | xargs -r rm -rf
echo "deployed $RELEASE"
Line by line, the non-obvious parts:
set -euo pipefail stops the script on the first failure. Without it, a failed migration would quietly continue to the symlink swap and put the new code live against a half-migrated database.
rsync -a --delete into a fresh directory is deliberately wasteful. I could rsync into the previous release and save disk, but then a failed rsync would corrupt my rollback target. Disk is cheap. A corrupt rollback target at 23:00 is not.
The rm -rf before the storage and uploads symlinks is because the build process might have created empty directories with those names, and ln -sf refuses to overwrite a directory. I know this because I have shipped a deploy where uploads silently went to a different directory than the previous release’s uploads were in, and nobody noticed for two days.
mv -T is the atomic part. Without -T, if current is a directory (which it will be if the symlink got deleted somehow), mv moves current.new into current/ rather than over it. -T forces “treat target as a file, not a directory.” That is what makes this rename atomic.
The permissions trap
The piece everyone trips over: shared uploads.
Your deploy script runs as some user — deploy, ubuntu, your personal account. Your web server runs as www-data or nginx or apache. When the user uploads a file through the app, the web server user writes it into /srv/www/example.com/shared/uploads/.
If the deploy user’s umask is 022, and the deploy creates a file in shared/uploads/, that file will be owned by the deploy user and not writable by the web server user. Future writes fail silently (or with 500s, depending on the framework). Uploads “stop working” in a way that no amount of restarting fixes.
Two defences against this.
First, set the setgid bit on shared/ and all its children. I did this above with chmod 2775. This makes any new file in those directories inherit the group, not the owner’s primary group. As long as the deploy user is a member of the web server’s group (which you should ensure), files created will have the right group.
Second, for the paranoid: the deploy script should never create files in shared/. It should only read from and symlink into. If you ever find yourself writing cp something $APP_ROOT/shared/ in a deploy script, you are asking for this trap.
Rolling back
Rollback is one line:
ln -sfn "$APP_ROOT/releases/$(ls -1t $APP_ROOT/releases | sed -n '2p')" "$APP_ROOT/current.new" && mv -T "$APP_ROOT/current.new" "$APP_ROOT/current" && systemctl reload nginx php8.3-fpm
Yes, that is deliberately one long command. You want your rollback to be copy-pasteable, not a multi-step procedure that someone has to think about at 23:00.
The sed -n '2p' picks the second-most-recent release (the first is the current one). If you want to roll back further, change 2p to 3p, 4p. This assumes you kept enough releases around, which you did (step 6 keeps five).
What this pattern does not handle
It does not help you with database rollbacks — DROP COLUMN is not reversible no matter what the code does. It does not help you with file transfers that span multiple servers. It does not give you canary releases or traffic shifting.
What it gives you is: no dropped requests during deploys, rollback in well under 60 seconds, and the ability to inspect any past release as a directory you can cd into.
For 95% of the projects I see, that covers the hard part. The rest is specific to the app and does not generalise.