Deploy your Laravel app from scratch

Deploy with zero-downtime

Episode 4
3 years ago
8 min read

In the previous episode, we installed Deployer and defined our deployment flow by configuring a deploy.yaml file at the root of our project. Enough theory, it's about time we ship this thing to our server.

Ship it!

In our deploy.yaml file, we configured our deployment flow inside the deploy task. Therefore, all we need to do to start a deployment is to run:

dep deploy

If everything went well, we should have the following output.

Console output of the first deployment.
[prod] info deploying main
task deploy:setup
task deploy:lock
task deploy:release
task deploy:update_code
task deploy:shared
task deploy:writable
task deploy:vendors
task artisan:storage:link
task artisan:view:cache
task artisan:config:cache
task artisan:migrate
[prod] warning Your .env file is empty! Skipping...
task npm:install
task npm:run:prod
task deploy:symlink
task deploy:unlock
task deploy:cleanup
[prod] info successfully deployed!
task php-fpm:reload

What happened?

Our application was successfully deployed to our server using a technique that causes no downtime to your users. We call this “zero-downtime”.

Concretely, the following happens during each deployment:

  • A brand new folder is created and the entire project is cloned into it. We call that folder: a release. Deployer does that efficiently by caching things from one release to another.
  • Our deployment tasks are being executed inside that release. For example, deploy:vendor which executes composer install.
  • Once all tasks have been successfully executed, that release is marked as the new "current" release. It does that by creating a current symlink that points to the release folder we’ve just created.

Since all our deployment tasks are being executed before we symlink the directory to the new release, the current folder always acts as the latest stable release without ever experiencing any downtime.

This is why, when we configured Nginx on our server, we told it to serve our application inside that current folder by defining our root as /var/www/jollygood.app/current/public.

After this first deployment, if we look at the folder structure of our deploy_path inside our server — in our case /var/www/jollygood.app — we should see something like this.

A folder structure with the current folder pointing to the releases/1 folder containing our application.

As you can see our current folder which is responsible for representing the active release is pointing to the releases/1 folder — our very first release 🎉.

If we were to deploy again, then we would see the following folder structure during the deployment process.

A folder structure with the current folder still pointing to the releases/1 folder but a new releases/2 folder is being created.

At this point, the second release — located in releases/2 — is being built but any user accessing our application will still be using releases/1.

Once all tasks have been successfully executed, the current symlink is updated to point to that new release.

A folder structure with the current folder now pointing to the releases/2 folder. The folder releases/1 is still present.

In case you're wondering, the .dep folder exists for internal usage of the Deployer library. It is used to store some metadata and cache things such as our git repository so we don't clone it from scratch at every deployment.

If anything goes wrong during the deployment process, the user is none the wiser as they will continue to use the first release until we are sure the deployment was a success.

On top of that, if the deployment was successful but we later find out there is a bug in the latest release, we can roll back to a previous release by simply updating the current symlink again. Deployer allows you to do this via the dep rollback command.

Additionally, you may use the dep status command to list your latest available releases and their status — i.e. if they were successful or not and which one is the current release.

Terminal output of the "dep status" command. It shows a table with 5 columns: "Date", "Release", "Author", "Target" (the branch) and "Commit" (the commit hash). The table contains 6 releases from 1 to 6. The 6th release is marked as the current release.

Now you might be wondering: what about our .env file and our storage directory? Do they also get created from scratch after each deployment?

Thankfully not. Instead, they are put in a special shared folder. During the deployment of each release, we create symlinks that point inside that shared folder so that every release uses the same .env file and the same storage directory.

By default, only .env and storage are configured as shared by the Laravel recipe but you may explicitly configure which files and folders should be shared via the shared_files and shared_dirs options respectively.

A folder structure with a shared folder containing a .env file and a storage folder. The folder structure also contains two releases: releases/1 and releases/2. Both of these releases contain a full Laravel application such that their .env file and their storage folders are symlinks pointing to the content of the shared folder.

However, on the very first deployment, we don’t have a .env file on our repository. Therefore, a brand new empty one is created and added to our shared folder.

This is why, if you remember the output of our first deployment, we were not able to migrate our database.

Console output of the first deployment.
[prod] info deploying main
task deploy:setup
task deploy:lock
task deploy:release
task deploy:update_code
task deploy:shared
task deploy:writable
task deploy:vendors
task artisan:storage:link
task artisan:view:cache
task artisan:config:cache
task artisan:migrate
[prod] warning Your .env file is empty! Skipping...
task npm:install
task npm:run:prod
task deploy:symlink
task deploy:unlock
task deploy:cleanup
[prod] info successfully deployed!
task php-fpm:reload

Additionally, without a .env file, our application is lacking the mandatory APP_KEY environment variable which is why, if you try to visit your application, you’ll see a 500 error.

So let’s fix that.

Setting up our environment

We’ll start by running the following command to SSH into our server.

dep ssh

Now that we have deployed our application, we arrive directly in the current release.

Now, if we run ls -la, you’ll notice that the .env file exists and is symlinked to a .env file on our shared folder as we’ve mentioned above.

Terminal output of the "ls -la | grep env" command on the server. It shows that the ".env" file is a symlink to "../../shared/.env".

However, that file is empty so let’s fill it. We’ll run the following commands to start from our .env.example file and to generate the APP_KEY environment variable randomly.

cp .env.example .env
php artisan key:generate

Note that running the cp command will keep the symlink to the shared folder which is exactly what we want.

Next, vim into your .env file to configure your production environment variables.

  • Ensure APP_NAME is set to your application name.
  • Ensure APP_ENV=production in production
  • Ensure DEBUG=false in production
  • Ensure APP_URL is set to your production base URL — using https:// if you set up a SSL certificate as we did in episode 2.
  • Ensure your DB_* variables are pointing to your production database otherwise the artisan:migrate task will fail on our next deployment.

Screenshot of a terminal editing our ".env" file using vim.

Now, if you save and exit — :wq — you’ll still see a 500 when you try to view your application. That’s because we included the following task on our deployment flow: artisan:config:cache. Caching our configuration is good for performance but we’ve got to remember to regenerate the cache after manually editing the .env file.

If you’re already inside your server, simply run php artisan config:cache, otherwise, you may run dep artisan:config:cache to run this on your server from your local machine.

# Inside the current release.
php artisan config:cache

# In your local machine.
dep artisan:config:cache

If you're still seeing a 500 at this point, it could either be a cache issue from PHP-FPM or it could be that the page you're requesting requires access to the database which we haven't yet migrated at this point. Both of these will be automatically fixed by our next deployment so bear with me before entering panic mode. 😊

Finally, let’s start a brand new deployment. This will ensure our deployment flow works properly and that the database is migrated now that we’ve set up our .env file. Back to your local machine run dep deploy again. You should now see the following console output.

Console output of the second deployment. Same as the first one but without the warning after the artisan:migrate task.

And that’s it! You’re now fully equipped to deploy your application at any time with two simple words!

Okay now you can start to panic if you're still seeing a 500. 😅 Check the logs using dep logs or by manually checking the log files on your server. Feel free to comment your problem below so others can help you and so you can help others facing the same issue in the future.

Running tasks after deploying

Now that we have a better understanding of "zero-downtime", there's one last thing I'd like to mention before wrapping up this article.

You might have noticed that tasks can be executed both during and after a deployment. For example, if you want to regenerate your configuration cache after updating your .env file, you can run:

dep artisan:config:cache

Since we are not in the middle of a deployment, the task will be executed directly inside the current directory — as opposed to inside the release that is being built.

When creating custom tasks in Deployer you have access to many paths but here are the most important ones you’ll want to know:

  • current_path. This is the path of the active release. The one that users interact with when they go on your application.
  • release_path. This is the path of the release which is being built. It has not yet been symlinked so users do not see it yet but at the end of the deployment flow — if everything goes well — it will become the new current_path.
  • release_or_current_path. This will always be equal to the release_path unless it does not exist — meaning we are not deploying. In this case, it will equal the current_path instead. This path represents the "best of both worlds" and I recommend using this one when possible.

Therefore, we could update our npm:run:prod custom task inside our deploy.yaml file to make sure it can also be executed outside a deployment if we need to.

  tasks:
    ...
    npm:run:prod:
-     - run: 'cd {{release_path}} && npm run prod'
+     - run: 'cd {{release_or_current_path}} && npm run prod'

All artisan commands provided by the Laravel recipe are using this release_or_current_path variable which means you can use them inside and outside deployments.

The only exceptions to this rule are the artisan:up and artisan:down tasks which will always use the current_path even when they are inside a deployment. This is by design since, even when deploying, we’ll want maintenance mode changes to apply to our active release.

Conclusion

Congratulations, We did it! 🥳

It took us 4 episodes but we went from nothing to having our Laravel application deployed in a fully configured server using only 2 words: dep deploy.

You can find the updated deploy.yaml for this episode here — or the equivalent deploy.php here.

Where do we go from here? Well, having a set of predefined recipes and tasks is super helpful but there's no doubt you'll want to add more custom logic to your deployment flow either now or in the future. Fortunately, Deployer allows great flexibility by letting us code our own recipes in PHP which is exactly what we'll do in the next episode. See you there!

Discussions

Author avatar
Steen Rabol
3 years ago

Hi

Nice series of articles.

There is a few things that does not really work as described :)

Of course it could be my local computer or server so bare with me.

Local: Mac, dev is on Homestead, but I have installed php8, composer, npm locally

dep init does not work, one have to do: php vendor/bin/dep init

a sample of ~/.ssh/config would be help full

you cannot have you cannot have artisan:down/up in the initial deployment, the deployment will fail as there is nothing to 'down'

php-fpm reload might fail if the user that is deploying does not have password less sudo

the permissions on the folders might not be correct so ngix might not be able to serve your site and subsequent deployments might fail if you don't set the permissions correct after the initial deployment.

💖 1

Discussion

Deploy with zero-downtime
Author avatar
Steen Rabol
3 years ago

Hi

Nice series of articles.

There is a few things that does not really work as described :)

Of course it could be my local computer or server so bare with me.

Local: Mac, dev is on Homestead, but I have installed php8, composer, npm locally

dep init does not work, one have to do: php vendor/bin/dep init

a sample of ~/.ssh/config would be help full

you cannot have you cannot have artisan:down/up in the initial deployment, the deployment will fail as there is nothing to 'down'

php-fpm reload might fail if the user that is deploying does not have password less sudo

the permissions on the folders might not be correct so ngix might not be able to serve your site and subsequent deployments might fail if you don't set the permissions correct after the initial deployment.

💖 1
Author avatar
Loris Leiva
3 years ago

Hi Steen, thanks! I'm glad you're enjoying the series.

Thanks for adding these points, I'm sure others might find them useful as well.

  • Make sure you have the path ./vendor/bin added to your $PATH variable. Then you'll be able to call dep directly instead of vendor/bin/dep.
  • If you're not familiar with the ~/.ssh/config file, I recommend this article.
  • Good to know for the artisan:up/down tasks. I'll make note of it and see if I can fix it by just showing a warning if the {{current_path}} does not yet exists.
  • That's correct. If that's the case then, you'll always need a TTY to restart PHP-FPM which means you won't be able to add it to your deployment flow. Personally, I've never worked with a server that doesn't support it but it's a valid point.
  • If you've built your server by following the process of the second episode, or using either Laravel Forge or Ploi, then the permissions should be set correctly.

I hope this helps!

💖 0
Author avatar
Marco Maldonado
3 years ago

with Laravel Deployer (we have to update our projects) we usually create a user called "deploy" and resolve this with:

$ sudo visudo
# At the end of the file
deploy  ALL=NOPASSWD: /bin/systemctl reload php7.3-fpm, /bin/systemctl reload php7.4-fpm

Now the user deploy can only use this commands without a password or being sudoer.

💖 0

Would you like to chime in?

You must be a member to add a reply to a discussion.

Fortunately, it only takes two click to become one. See you on the other side! 🌸

Become a Member
Author avatar
David
2 years ago

Hi, First of all, great tutorial, it helped me a lot. I just face an "interesting" issue. I am able to deploy now my code in my Droplet, but the problems comes after. During the first deployment, I got a failure because of the migration script - as expected as my DB wasn't set. After I filled my .env fill correctly, the deployment succeeded, but since the failed migration my laravel.log doesn't log anything, the last entry is from the failed migration.

I tried to clear cache, restart nginx and my droplet, change permission and ownership of the shared storage folder and its content, but still, Laravel is not logging anything anymore... Any idea why? could it be that with the newest deployer version something changed and that cause the issue? any help is welcome.....

💖 0

Discussion

Deploy with zero-downtime
Author avatar
David
2 years ago

Hi, First of all, great tutorial, it helped me a lot. I just face an "interesting" issue. I am able to deploy now my code in my Droplet, but the problems comes after. During the first deployment, I got a failure because of the migration script - as expected as my DB wasn't set. After I filled my .env fill correctly, the deployment succeeded, but since the failed migration my laravel.log doesn't log anything, the last entry is from the failed migration.

I tried to clear cache, restart nginx and my droplet, change permission and ownership of the shared storage folder and its content, but still, Laravel is not logging anything anymore... Any idea why? could it be that with the newest deployer version something changed and that cause the issue? any help is welcome.....

💖 0

Would you like to chime in?

You must be a member to add a reply to a discussion.

Fortunately, it only takes two click to become one. See you on the other side! 🌸

Become a Member
Author avatar
Tony
7 months ago

Great-Great-Great Article! Thank you!

💖 0

Discussion

Deploy with zero-downtime
Author avatar
Tony
7 months ago

Great-Great-Great Article! Thank you!

💖 0

Would you like to chime in?

You must be a member to add a reply to a discussion.

Fortunately, it only takes two click to become one. See you on the other side! 🌸

Become a Member

Would you like to chime in?

You must be a member to start a new discussion.

Fortunately, it only takes two click to become one. See you on the other side! 🌸

Become a Member