Zero downtime deployment for Laravel apps

Feb 19, 2022

While I was building laravelremote.com (a Laravel remote job aggregator), I was worried that each time I wanted to push some changes to the production server, it might take the website down for a couple of seconds. Which probably isn't a big deal, but I still wanted to avoid it.

Laravel offers a first-party paid product to avoid this, Envoyer it's only $10 bucks a month. But laravelremote.com doesn't generate any revenue right now, and I'm the type of person that likes to do things in-house to learn how it works, and I also like the freedom that it provides.

So with that in mind, I figured out a way to do this for free, and I wanted to share this in case someone else finds it useful.

The theory

I searched around the internet to see how Envoyer works under the hood, I don't exactly remember where I saw this, but basically, it works like this:

We have 3 directories that relate to our project:

  • releases/
  • current/
  • storage/

A new directory will be created in the releases/ directory each time we need to deploy a change to the website, then we run some "set up" commands (ex. composer install, move the cache routes, views, etc.), and we add a symbolic link from the releases' storage directory to the "permanent" storage/ directory, since we don't want to forget sessions and stuff like that on each deployment. After all of this, we add a symbolic link to the current/directory that points to the new release we just created.

This article is not a tutorial on how to deploy a Laravel app, so I'm not going to explain my NGINX configuration. The only thing you need to know is that my NGINX server is configured to read my website from the /var/www/laravelremote.com/ directory. This directory has another symbolic that points to the current/ directory.

More simply, every new release will be deployed then linked to the current directory, which will make the server show the updates.

The practice

Let's put that into practice, first I created a base directory that will contain everything we need for the project. I like to add this directory under the home folder and give it the name of the app

mkdir laravelremote/

since we are going to be working in this directory, we should cd into it

cd laravelremote

Now we need to create the storage directory and the other subdirectories that Laravel uses.

mkdir storage/
mkdir -p storage/cache/data/
mkdir storage/sessions/
mkdir storage/views/

These are the default Laravel directories that exist within the storage directory.

After that, we need to create the releases folder.

mkdir releases/

Also, we should create and global .env file that we should link to on every release.

touch .env

Add to this file the production environment variables.

The current directory will be created once we deploy the project for the first time, so there is no need to create it right now.

Deploying

We could create a new directory in the releases' directory, pull the main branch from GitHub, run every other needed command, and link everything at the end to the current directory. But that seems like a lot of steps for simple deployment, so I created a bash script to do that instead:

#!/bin/sh

UNIX_TIME=$(date +%s)

DEPLOYMENT_DIRECTORY=$UNIX_TIME

mkdir -p $DEPLOYMENT_DIRECTORY
git clone --depth 1 --branch master [email protected]:username/reponame.git $DEPLOYMENT_DIRECTORY

echo removing storage
rm -Rf $DEPLOYMENT_DIRECTORY/storage

cd $DEPLOYMENT_DIRECTORY

cp ~/laravelremote/.env .env

echo link master storage
ln -s -n -f -T ~/laravelremote/storage ~/laravelremote/releases/$DEPLOYMENT_DIRECTORY/storage

composer install --no-dev
npm install
npm run prod

echo view:cache command
php artisan view:cache

echo route:cache command
php artisan route:cache

echo config:cache command
php artisan config:cache

echo migrations
php artisan migrate --force


cd ..

#link to the latest deployment from Live
ln -s -n -f -T ~/laravelremote/releases/$DEPLOYMENT_DIRECTORY ~/laravelremote/current

echo Done!

This script lives under the releases/ folder. It creates a new directory for the new release with the current UNIX timestamp, then it pulls the repo from GitHub into that directory. After that, I remove the storage folder from that directory, copy the .env file that contains the production keys (you might want to create a symbolic link instead), and then link the "permanent" storage file to the current release.

After that, I run some necessary commands for the project to work.

  • Install the composer dependencies
  • Install the NPM dependencies and build the resources. (I don't commit my build resources, other people might do it differently)
  • Cache the views, routes, and config for optimization
  • Run the database migrations

Then finally, we link the current directory to this release, which will publish the changes.

And that's it, whenever I push something to the master branch, I ssh into the server, and then I run this script, and my changes are deployed with 0 downtime.

Possible improvements

I still have some things that I don't like about this approach. One of them is that I would like to make it, so it automatically deploys the changes when I push them to GitHub without having to ssh into the server. Another one is that the old versions are not removed automatically, I currently remove them manually, but I would like to automate this too. And since we are talking about that, I noticed that Envoyer has a way to roll back the changes if something goes wrong, so you can link one of the previous versions if you need to. That would probably need a more complicated setup on my part, but It would be interesting to do.

Regardless, I'm pretty happy with how this turned up, even though I still want to improve it. If you would like to hear about those future improvements follow me on Twitter, I'll probably post any developments there.