Laravel Zero Downtime Deployment Script

A nice zero downtime deployment script for Laravel apps.

Zacharias Creutznacher Zacharias Creutznacher

Deploying a Laravel application without downtime is essential for maintaining a seamless user experience. This deployment script achieves exactly that, ensuring a smooth transition between releases. It clones the latest version of your project, sets up environment files, installs dependencies, runs migrations, and links the new release—all while keeping your application live. With built-in clean-up for old releases and error handling, this script is a robust solution for modern Laravel deployments.

 1# SETUP #
 2DOMAIN=example.com
 3PROJECT_REPO="[email protected]:example.com/app.git"
 4AMOUNT_KEEP_RELEASES=5
 5
 6RELEASE_NAME=$(date +%s--%Y_%m_%d--%H_%M_%S)
 7RELEASES_DIRECTORY=~/$DOMAIN/releases
 8DEPLOYMENT_DIRECTORY=$RELEASES_DIRECTORY/$RELEASE_NAME
 9
10# stop script on error signal (-e) and undefined variables (-u)
11set -eu
12
13printf '\nℹ️ Starting deployment %s\n' "$RELEASE_NAME"
14mkdir -p "$RELEASES_DIRECTORY" && cd "$RELEASES_DIRECTORY"
15
16printf '\nℹ️ Clone GIT project from %s and checkout branch %s\n' "$PROJECT_REPO" "$FORGE_SITE_BRANCH"
17git clone "$PROJECT_REPO" "$RELEASE_NAME"
18cd "$RELEASE_NAME"
19git checkout "$FORGE_SITE_BRANCH"
20git fetch origin "$FORGE_SITE_BRANCH"
21git reset --hard FETCH_HEAD
22
23printf '\nℹ️ Link env file\n'
24ENV_FILE=~/"$DOMAIN"/shared/.env
25if [ -f "$ENV_FILE" ]; then
26  rm -rf ./env
27  ln -s -n -f -T $ENV_FILE ./.env
28else
29  printf '\nError: env file is missing at %s.' "$ENV_FILE" && exit 1
30fi
31
32printf '\nℹ️ Link storage folder\n'
33STORAGE_DIR=~/"$DOMAIN"/shared/storage
34if [ -d "$STORAGE_DIR" ]; then
35  rm -rf ./storage
36  ln -s -n -f -T $STORAGE_DIR ./storage
37else
38  printf '\nError: storage dir is missing at %s.' "$STORAGE_DIR" && exit 1
39fi
40
41printf '\nℹ️ Install Composer Dependency Updates\n'
42$FORGE_COMPOSER install --no-interaction --prefer-dist --optimize-autoloader --no-dev
43
44printf '\nℹ️ Installing NPM dependencies based on \"./package-lock.json\"\n'
45npm ci
46printf '\nℹ️ Generating JS App files\n'
47npm run build
48
49printf '\nℹ️ Laravel Artisan commands\n'
50if [ -f artisan ]; then
51  printf '\nℹ️ Link ./public/storage\n'
52  $FORGE_PHP artisan storage:link
53
54  printf '\nℹ️ Clear and cache routes, config, views, events\n'
55  $FORGE_PHP artisan config:cache
56  $FORGE_PHP artisan route:cache
57  $FORGE_PHP artisan view:cache
58  $FORGE_PHP artisan event:cache
59
60  printf '\nℹ️ Database Migrations\n'
61  $FORGE_PHP artisan migrate --force
62fi
63
64printf '\nℹ️ !!! Link Deployment Directory !!!\n'
65echo "$RELEASE_NAME" >> $RELEASES_DIRECTORY/.successes
66if [ -d ~/$DOMAIN/current ] && [ ! -L ~/$DOMAIN/current ]; then
67  rm -rf ~/$DOMAIN/current
68fi
69ln -s -n -f -T "$DEPLOYMENT_DIRECTORY" ~/$DOMAIN/current
70
71# if your "fastcgi_param SCRIPT_FILENAME" is set to "$realpath_root$fastcgi_script_name" you do not need to restart fpm,
72# which can lead to droped or failed requests.
73printf '\nℹ️ Restart PHP FPM\n'
74( flock -w 10 9 || exit 1
75    echo 'Restarting FPM...'; sudo -S service $FORGE_PHP_FPM reload ) 9>/tmp/fpmlock
76
77printf '\nℹ️ Restart Horizon Queue Workers\n'
78  $FORGE_PHP artisan horizon:terminate
79  
80# Clean Up
81cd $RELEASES_DIRECTORY
82
83printf '\nℹ️ Delete failed releases:\n'
84if grep -qvf .successes <(ls -1)
85then
86  grep -vf .successes <(ls -1)
87  grep -vf .successes <(ls -1) | xargs rm -rf
88else
89  echo "No failed releases found."
90fi
91
92printf '\nℹ️ Delete old successful releases:\n'
93AMOUNT_KEEP_RELEASES=$((AMOUNT_KEEP_RELEASES-1))
94LINES_STORED_RELEASES_TO_DELETE=$(find . -maxdepth 1 -mindepth 1 -type d ! -name "$RELEASE_NAME" -printf '%T@\t%f\n' | head -n -"$AMOUNT_KEEP_RELEASES" | wc -l)
95if [ "$LINES_STORED_RELEASES_TO_DELETE" != 0 ]; then
96  find . -maxdepth 1 -mindepth 1 -type d ! -name "$RELEASE_NAME" -printf '%T@\t%f\n' | sort -t $'\t' -g | head -n -"$AMOUNT_KEEP_RELEASES" | cut -d $'\t' -f 2-
97  find . -maxdepth 1 -mindepth 1 -type d ! -name "$RELEASE_NAME" -printf '%T@\t%f\n' | sort -t $'\t' -g | head -n -"$AMOUNT_KEEP_RELEASES" | cut -d $'\t' -f 2- | xargs -I {} sed -i -e '/{}/d' .successes
98  find . -maxdepth 1 -mindepth 1 -type d ! -name "$RELEASE_NAME" -printf '%T@\t%f\n' | sort -t $'\t' -g | head -n -"$AMOUNT_KEEP_RELEASES" | cut -d $'\t' -f 2- | xargs rm -rf
99else
100  AMOUNT_KEEP_RELEASES=$((AMOUNT_KEEP_RELEASES+1))
101  LINES_STORED_RELEASES_TOTAL=$(find . -maxdepth 1 -mindepth 1 -type d -printf '%T@\t%f\n' | wc -l)
102  printf 'There are only %s successfully stored releases, which is less than or equal to your\ndefined %s releases to keep, so none of them got deleted.' "$LINES_STORED_RELEASES_TOTAL" "$AMOUNT_KEEP_RELEASES"
103fi
104
105printf '\nℹ️ Status - stored releases:\n'
106find . -maxdepth 1 -mindepth 1 -type d -printf '%T@\t%f\n' | sort -nr | cut -f 2-
107
108printf '\n�
109 Deployment DONE: %s\n' "$DEPLOYMENT_DIRECTORY"