Deploying a Change

Ship a change without a half-copied application: a symlink release, an FPM reload for opcache, additive migrations, and a tested rollback.

Syntaxln -sfn "$REL" /srv/shop/current-tmp && mv -T /srv/shop/current-tmp /srv/shop/current

The danger in a deploy is the middle of it. If you copy files into the directory that is serving traffic, there is a moment when half the application is new and half is old, and whoever requests a page then gets an error no test ever produced. The fix is to build the new version somewhere else and switch over in a single step.

An atomic release

Unpack the build into releases/20261004T120000, link the shared pieces (the .env file, the uploads directory), then move the current symlink. ln -sfn new current-tmp followed by mv -T current-tmp current replaces the link with one rename, so no request ever sees a partial tree. Rolling back is the same two commands aimed at the previous release.

Clear the right cache

With opcache.validate_timestamps = 0 PHP never re-checks file timestamps, so the old code stays compiled until FPM is reloaded. systemctl reload php8.5-fpm finishes in-flight requests and starts fresh workers. Running opcache_reset() from the command line clears the CLI's own cache and changes nothing for the web.

Migrations go in two halves

Expand before, contract after. Additive changes - a nullable column, a new index - ship first and are invisible to the running code. Destructive changes wait for a later release, once nothing reads the old shape. Backfill in batches so one statement does not lock a table.

A rollback you have run

Practise it on a quiet afternoon. The database does not roll back with the code, which is exactly why migrations are additive.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Release a change at midday without a maintenance page.
  • Undo a bad release in seconds by pointing the symlink back and reloading FPM.
  • Rename a database column across three releases while the site stays up.

More examples

The deploy script

set -euo pipefail stops the script at the first failing command, so a failed extraction never reaches the symlink swap. The order is the whole lesson: everything slow and fallible happens before the switch, and the switch itself is one rename. The readiness check right after the reload is what turns a bad release into a known bad release. Keeping five old releases is what makes the rollback a one-line command rather than a rebuild.

Example · bash
#!/usr/bin/env bash
set -euo pipefail

APP=/srv/shop
REL="$APP/releases/$(date -u +%Y%m%dT%H%M%SZ)"

mkdir -p "$REL"
tar -xzf /tmp/shop-build.tar.gz -C "$REL"

ln -sfn "$APP/shared/.env" "$REL/.env"
ln -sfn "$APP/shared/uploads" "$REL/var/uploads"

php "$REL/bin/migrate.php" --expand

ln -sfn "$REL" "$APP/current-tmp"
mv -T "$APP/current-tmp" "$APP/current"

systemctl reload php8.5-fpm
curl -fsS --max-time 5 http://127.0.0.1/ready > /dev/null

ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf

Expand and contract, across three releases

At every single moment, the code that is running and the schema that exists agree. That is what lets you roll the code back to release 41 after deploying 42 without touching the database. Renaming a column in one step breaks this: the old code queries a name that no longer exists, and the rollback cannot bring it back because the data moved.

Example · sql
-- Release 41, before the code ships: additive only.
ALTER TABLE customers ADD COLUMN email_normalised VARCHAR(255) NULL;
CREATE INDEX idx_customers_email_normalised
    ON customers (email_normalised);

-- Release 41 code writes both columns and still reads the old one.
-- Release 42 code reads email_normalised.

-- Release 43, once no running process touches the old column:
ALTER TABLE customers DROP COLUMN email;

A backfill that does not lock the table

One UPDATE over ten million rows holds locks for minutes and times out the site. Five hundred rows per transaction, with a tenth of a second between batches, leaves room for real traffic and can be stopped and restarted at any point, because the WHERE clause only selects rows not yet done. Both statements are prepared once and executed many times, which is both faster and the only form that is safe against SQL injection.

Example · php
<?php

declare(strict_types=1);

// bin/backfill-normalised-email.php, run after the expand migration.
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$select = $pdo->prepare(
    'SELECT id, email FROM customers
     WHERE email_normalised IS NULL ORDER BY id LIMIT 500'
);
$update = $pdo->prepare(
    'UPDATE customers SET email_normalised = :norm WHERE id = :id'
);

while (true) {
    $select->execute();
    $rows = $select->fetchAll(PDO::FETCH_ASSOC);

    if ($rows === []) {
        break;
    }

    $pdo->beginTransaction();

    foreach ($rows as $row) {
        $update->execute([
            'norm' => mb_strtolower(trim((string) $row['email'])),
            'id' => $row['id'],
        ]);
    }

    $pdo->commit();
    usleep(100_000);
}

Discussion

  • Be the first to comment on this lesson.