Invite-only beta · 10 places · Managed hosting on AWS

AceMedia HostRequest invite

HOSTING TIPS · 2 OCTOBER 2026

Deploys that cannot half-happen

A deploy should switch a site from one complete version to another in a single step. If anything after the switch fails, it should put things back on its own.

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
fi

Automatic 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:9000

Detail 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/releases

Detail 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"
done

Keep secrets out of all of this. Configuration files with passwords belong in a private folder that the deploy copies in, never in the repository.

More hosting tipsTalk about your hosting

LET’S TALK

Want a server set up like this?

We run sites on a stack built with these habits. Tell us about yours and we’ll give you a straight answer about fit.

Ask for an invite

INVITE-ONLY BETA

Tell us about your site.

We’re inviting ten beta projects to help us test and shape the service. Tell us what you’re building; we’ll reply personally. Fields marked * are required.

Please do not send passwords or private customer data. We’ll use these details to reply. Privacy notice.

Prefer email? Write to [email protected].