Using LEMP stack on Ubuntu to host a Laravel app

Using LEMP stack on Ubuntu to host a Laravel app

Over the last couple of years, NGINX (part of the LEMP stack) has been significantly gaining popularity until it finally surpassed its main rival in market share, Apache (part of the LAMP stack), making it currently the most used web server. The LEMP stack, which we will learn to configure in this article, consists of 4 parts: a Linux operating system (or a Linux-based distribution, in this case, Ubuntu), a NGINX web server, a MySQL database, and PHP. Beyond that, in order to have a fully functional Laravel app, we are going to install and configure Composer, Supervisor, and an SSL certificate. Along the way, we are also going to perform basic UFW and cron setups, and I'll explain how we can clone and host an existing project from GitHub. In the end, we are going to create a small shell script with which we will run a set of commands that are useful when deploying updates to the codebase located on the server. 

I'll be installing everything on a very basic development server, which runs on Ubuntu 22.04 LTS, and has 1 CPU core, and 1 GB of RAM. In order to perform the necessary steps, you will need to SSH into your server as a root user (although it is recommended that you don't use a root user actually) or as any other user with sudo privileges. For that purpose, I'll be using Git Bash on Windows 10

A little note about Ubuntu versions before we proceed. When I first started writing the article, the latest Ubuntu LTS version was 22.04, but in the meantime, a new version of Ubuntu was released, 24.04 LTS. After finishing the article, I've created a new server with a 24.04 version, went through the whole write-up again, and tested each command on that new server. So the instructions you are about to go through should execute about the same on both versions.

I won't go into too many details when it comes to accessing your server for the first time, but usually, if using SSH keys (if you are not using SSH keys you will be prompted for password, but generally using the keys is a more secure and recommended way), when creating a new server instance your hosting provider will allow you upload a public SSH key (or use an existing one which you assigned to your account) which will be added to the authorized keys on the server. Later on, when trying to SSH into the server, that key will be checked against your private SSH key on your computer, and you will be allowed to access the server's terminal.

As you may already suspect, this article will cover quite a few topics, so take your time and feel free to take breaks between chapters. 

Now that we have a nice overview of the steps we are going to perform, it's time to get our hands dirty.


Configuring UFW, NGINX, MySQL, and PHP


First, SSH into the server as a user with sudo privileges as you usually would, by using a public IP address and a username. For example, like so:


ssh [email protected]

Or if you have multiple SSH keys, specify the key:


ssh -i /path/to/private/key.pem [email protected]

Next, we are going to update our server's package index:


sudo apt update

Now we are going to take a look at our UFW settings. UFW stands for Uncomplicated Firewall. We are going to use it to allow access to our server only through SSH, HTTP, and HTTPS (meaning ports 22, 80, and 443, respectively). We can check the current status of the firewall by typing this command:


sudo ufw status

Don't worry if the firewall isn't active yet; we are going to enable it in just a sec. We can also use another command to see a list of the available applications (basically preconfigured profiles that can be used to allow different types of traffic):


sudo ufw app list

Currently, on that list, you might see only an OpenSSH profile, which is used to allow SSH connections to the server. Before we proceed, let's specifically allow SSH access through the firewall with this command (even though the UFW isn't active yet, we are going to need this profile enabled later on):


sudo ufw allow ssh

Great, now let's install NGINX (when prompted, press Y and ENTER to continue):


sudo apt install nginx

Confirm that everything went as planned by checking the status:


service nginx status

If the service is active and running, we are good to proceed. Now, since we won't be using SSL just yet, we are going to enable just HTTP traffic to our server with this command:


sudo ufw allow 'Nginx HTTP'

Finally, we can enable UFW like so (press y when prompted):


sudo ufw enable

When checking the UFW status again (sudo ufw status), you might see something like this:


Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
Nginx HTTP                 ALLOW       Anywhere
22/tcp (v6)                ALLOW       Anywhere (v6)
Nginx HTTP (v6)            ALLOW       Anywhere (v6)

This just means that currently only SSH and HTTP traffic (on default ports 22, and 80) is allowed. Attempts to access your server on any other ports will be prevented by the firewall. 

If you configured everything properly, you should be able to see a default NGINX page when visiting your server's public IP address (which we also used with SSH), for example: 


http://203.0.113.0


NGINX default page

Nice! Now let's install MySQL (when prompted, press Y and ENTER to continue).


sudo apt install mysql-server

At the time of writing this article, the latest version of MySQL was 8.0.36. You can check if the service is running with this command:


sudo systemctl status mysql

After we have confirmed that everything is in order, we should run a MySQL security script that will perform certain adjustments to our installation, making it more secure. We can start by running this command:


sudo mysql_secure_installation

After running the command, you will be prompted if you would like to enable a VALIDATE PASSWORD component. If enabled, any password you set will need to satisfy certain conditions, depending on the level you choose. That basically means that you will be forced to use stronger passwords when setting up your databases. Which is, of course, a good thing; you just need to make sure your passwords are complex enough. Even though this step isn't required, we are going to type Y and see what's next. After you decide to enable the component, you will be asked to select a level of password complexity:


There are three levels of password validation policy:

LOW    Length >= 8
MEDIUM Length >= 8, numeric, mixed case, and special characters
STRONG Length >= 8, numeric, mixed case, special characters and dictionary file

Please enter 0 = LOW, 1 = MEDIUM and 2 = STRONG:

We are going to use the strongest level. To proceed, type 2. After doing so, you will see that auth_socket authentication was set by default. Ignore this part for a second; I'll elaborate on it soon. Continuing with the setup, you will be asked to remove anonymous users, disallow root remote login, remove test databases, and reload the privilege tables. To each of those, you can respond with Y and ENTER. After those 4 additional confirmations, you should be all set.

Now, let's go back a bit to that auth_socket authentication message from a couple of moments before. The message stated something like this:


Skipping password set for root as authentication with auth_socket is used by default.
If you would like to use password authentication instead, this can be done with the "ALTER_USER" command.
See https://dev.mysql.com/doc/refman/8.0/en/alter-user.html#alter-user-password-management for more information.

What this means is that if you accessed your server as one of the users with sudo privileges, you won't have to type a password when wanting to access MySQL as its main/administrative (also called root) user. So, just to clarify, there is a system's (Ubuntu) root user and there is a MySQL's root user, and you are able to login as that MySQL's root user only if you have the sudo privileges of the system's root user. I hope that makes sense. The takeaway is that this setup makes access to MySQL more secure since only the system's root users can perform administrative tasks of the highest order. This also means that we won't be able to access MySQL databases from our Laravel app as MySQL's root user, but we will have to create other dedicated users with lesser privileges for each database.

If everything went well with a secure installation, you would be able to access MySQL by typing:


sudo mysql

There, you can check if everything is in order by performing a simple query to list all databases:


SHOW DATABASES;

To leave the MySQL shell, just type:


exit

Now, using auth_socket for authentication is a default for MySQL and a more secure option. But if you really want to specify a password for MySQL's root user, you can do so with the following set of commands. First, login to MySQL's shell again as the root user:


sudo mysql

Now, I'm going to copy all of the commands that should be executed in MySQL in a single block, so just keep in mind that they should be typed in the terminal one by one, row by row.


SELECT user,authentication_string,plugin,host FROM mysql.user;

ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password by 'G%xYT5yEg^YBTgY2nK&fZ7ka';

FLUSH PRIVILEGES;

SELECT user,authentication_string,plugin,host FROM mysql.user;

exit;

What happened above was that we first checked the authentication method for the root user, and we confirmed that it is currently set to auth_socket:


| root | auth_socket | localhost |

Then we set a password for the MySQL's root user using caching_sha2_password, and there is an example of a random password I just generated (G%xYT5yEg^YBTgY2nK&fZ7ka) to show you the type of password strength that is expected, based on the level we have chosen when we were running the secure installation script. Make sure to generate a password of your own. After that, we are flushing the privileges and running the same select query to confirm that the root user now indeed has a password set:


root | $A$005SOMESALTANDPASSWORD | caching_sha2_password | localhost |

In the end, we need to restart MySQL for the changes to take place:


sudo service mysql restart

Now, when it comes to MySQL 8 and some older PHP versions (before 7.4.4), default drivers that allow PHP to communicate with MySQL don't support the caching_sha2_password authentication method, so you might need to use mysql_native_password method when setting the password (more about that in the PHP docs), like so:


ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password by 'G%xYT5yEg^YBTgY2nK&fZ7ka';

In case you ever want to revert the root user back to auth_socket, you would need to login as the root user first, but this time using the password you set previously (note the -p flag for password):


mysql -u root -p

When you login with the password, run these commands again, row by row:


SELECT user,authentication_string,plugin,host FROM mysql.user;

ALTER USER 'root'@'localhost' IDENTIFIED WITH auth_socket;

FLUSH PRIVILEGES;

SELECT user,authentication_string,plugin,host FROM mysql.user;

exit;

Again, restart MySQL:


sudo service mysql restart

Whew, that's enough regarding MySQL for now. Let's proceed with installing PHP

NGINX needs an additional program that will act as a mediator between the web server and the PHP interpreter. That program is PHP-FPM (PHP FastCGI Process Manager). This just means that all PHP requests will be passed from NGINX to PHP-FPM for processing. If you want to learn more about PHP-FPM, you can refer to my other article, where I'm discussing the most important settings: A deeper dive into optimal PHP-FPM settings.

We will also need to install some additional modules and extensions that are needed for PHP to communicate with MySQL and to satisfy some other requirements for Laravel to function properly.

Ubuntu 22.04 comes with PHP 8.1 as a default version that can be installed, so if we want to install any other version of PHP, we will need to pull some other repositories first, which will offer us a variety of choices. You can achieve that with these two commands:


sudo apt install software-properties-common

sudo add-apt-repository ppa:ondrej/php

For the first command, you might get a notification that everything is already up-to-date, and that's fine. For the second command, press ENTER when prompted. Keep in mind that if using Ubuntu '24.04', the default PHP version is 8.3, so if you plan on installing that version only, there is no need to pull additional repositories.

Now let's install PHP-FPM (we will be using PHP 8.3) and the extensions (press Y and ENTER when prompted):


sudo apt install php8.3-fpm php8.3-mysql php8.3-curl php8.3-mbstring php8.3-xml 

Now, if you run:


php -v

You should get a confirmation that PHP 8.3 was installed. Bare in mind, that if you want to install some other PHP version that you will need to specify that explicitly in the command, for PHP-FPM and each extension, if you don't do that you might end up with a default 8.1 (or 8.3, if using Ubuntu 24.04) version. So, for example, if we wanted to install PHP 7.4, the command would look like this:


sudo apt install php7.4-fpm php7.4-mysql php7.4-curl php7.4-mbstring php7.4-xml 

That's it for this chapter. We installed the core of the LEMP stack, now let's see what else we need to install and configure to get our app running.


Configuring Composer, Database, and Laravel


In order to manage PHP packages in our project, we are going to need to install Composer. Composer has a few dependencies, most of which we already installed or are already available in Ubuntu (git, curl, php-cli). What's left is to install unzip to extract zipped archives. Let's do that now:


sudo apt install unzip

By the way, if you ever get a kernel upgrade screen followed by a services restart screen during this process, just switch to Ok and type ENTER:


Kernel upgrade screen


Services restart screen

After that, you can reboot the system with this command:


sudo reboot

Note that after executing this command, you will be logged out of the session, and you should wait a minute or so before attempting to SSH to the server again, giving it time to reboot.

Now, let's install composer (you can copy the whole command and run all of the lines at once; there is no need to go row by row):


curl -sS https://getcomposer.org/installer | php && 
sudo mv composer.phar /usr/local/bin/composer &&
chmod +x /usr/local/bin/composer

Note that there is a more secure way to install Composer by verifying that the installer is not corrupted. You can refer to the Command-line installation section on the  Composer's download page on how to achieve that.

If everything went well, Composer was installed, and we can confirm that by typing:


composer -v

Since we only have one PHP version, Composer will use that one when performing any actions, but in case you end up installing multiple versions of PHP and PHP CLI, you can switch the currently active PHP CLI version by using this command:


sudo update-alternatives --config php

When running the command above, you might end up with something like this:


There are 2 choices for the alternative php (providing /usr/bin/php).

  Selection    Path             Priority   Status
------------------------------------------------------------
* 0            /usr/bin/php8.3   83        auto mode
  1            /usr/bin/php7.4   74        manual mode
  2            /usr/bin/php8.3   83        manual mode

Press <enter> to keep the current choice[*], or type selection number: 1
update-alternatives: using /usr/bin/php7.4 to provide /usr/bin/php (php) in manual mode

Here, I just typed 1 then ENTER to switch to PHP CLI 7.4. You can revert the changes with the same command. And if you now type php -v again, you should see the updated version.

OK, great, we installed the Composer, now let's go back to MySQL for a few moments. If you remember, we said that we were going to need to create a separate user with lesser privileges and a separate database for our Laravel app. First, login as root as we did previously:


sudo mysql

Then create a new database named laravel_database like this:


CREATE DATABASE laravel_database;

In the next step, we are going to create a new user, laravel_user, and set a password for him. Again, please remember that in order for the password to be considered valid, it has to be of the highest level of complexity, as specified in the secure MySQL installation we performed previously. Also, again, make sure that you generate a password of your own:


CREATE USER 'laravel_user'@'%' IDENTIFIED WITH caching_sha2_password BY '!j1SehLN9FMurvtkcs9xp&R5';

Now, we are going to give full access to the laravel_user for the laravel_database we just created (but only to that database):


GRANT ALL ON laravel_database.* TO 'laravel_user'@'%';

That's it. You can type exit to abort the session of the root user and login as our newly created user, using the password we just set (paste the password when prompted):


mysql -u laravel_user -p

If you type:


SHOW DATABASES;

You will see our new database listed. You can exit from MySQL again. It's finally time to clone our existing Laravel project to the server and configure it. Since we installed Composer, you could also create a new Laravel project directly on the server, but it's much more likely that you will be in a situation where you will need to clone an existing project from GitHub.

Before that, let's check our existing folder structure. Currently, the /var/www/html folder already exists, and inside of it there is just one HTML page (index.nginx-debian.html), which is the default NGINX page we recently saw when we visited our server's public IP address. We can remove the whole html folder and create a new folder that will hold the Laravel project. Go to the /var/www folder first:


cd /var/www

and delete the html folder (and its contents) like so: 


sudo rm -r html

Then create a new empty folder named laravel:


sudo mkdir laravel

Then go inside that folder:


cd laravel

and clone the project from GitHub into the current laravel folder like so:


sudo git clone https://{PERSONAL_ACCESS_TOKEN}@github.com/goran-popovic/laravel.git .

Let me explain a bit more what is going on in the command above. There are numerous ways of cloning a GitHub repo, but in this case, I'm using an HTTPS URL of the private repo with a personal access token to authenticate myself. You can see that my username is goran-popovic, and the name of the repo is laravel. What's left is the token. You can check out GitHub docs on how to create a personal access token and which alternatives are available. In general, you would go to your Account settings > Developer settings and there create a fine-grained or a classic token, which you would paste before the @github.com part of the URL. Your token might start with ghp_ or github_pat_ depending of what token type you have chosen.

For security reasons it's not safe to run Composer as the root user, so we first need to change the ownership of files and folders in our current directory (laravel). I will set my current (non-root, but with sudo privileges) user as the owner and make them accessible to the webserver's (NGINX) group (www-data):


sudo chown -R $USER:www-data .

Then we can install the necessary composer packages:


composer install --optimize-autoloader --no-dev

Next, we also need to give the necessary permissions to the owner (our user) and the webserver (www-data) for all the files and directories in the project:


sudo find . -type d -exec chmod 775 {} \;

sudo find . -type f -exec chmod 664 {} \;

And lastly, we need to give the webserver the rights to read and write to the storage and bootstrap/cache folders:


sudo chgrp -R www-data storage bootstrap/cache

sudo chmod -R ug+rwx storage bootstrap/cache

We can proceed with the Laravel-specific setup that might be already familiar to you. First, we need to copy the example .env file:


cp .env.example .env

Then we can edit it:


nano .env

The most important details here to provide are those related to the database setup, like the database name, username, and password, all of which we created previously. Also, for the APP_URL environment variable, you can add your public IP address for now. We are going to revisit that later, when we get to the SSL certificate section. I'm going to set APP_ENV to development and APP_DEBUG to true in this example, but you can set those to production and false if you want, depending on how you would like to use the app later on. 


APP_NAME=Laravel
APP_ENV=development
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://203.0.113.0

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_database
DB_USERNAME=laravel_user
DB_PASSWORD=!j1SehLN9FMurvtkcs9xp&R5

Great! Now, we just need to run the artisan command to generate an app key:


php artisan key:generate

And another command to run the migrations (when prompted, select Yes if you set the APP_ENV to production):


php artisan migrate

Awesome! By now, we should have successfully configured our Laravel app. In the next chapter, we are going back to NGINX, for which we need to perform some additional adjustments, and we are also going to install an SSL certificate.


Revisiting NGINX and installing SSL


Currently, if you visit your public IP address (we used http://203.0.113.0 as an example), you will notice that a 404 (not found) response is being returned. In order for our Laravel app to be accessible through our public IP address, we are going to need to instruct NGINX on how the app should be served. That can be achieved by using configuration files. Let's create one:


sudo nano /etc/nginx/sites-available/laravel.conf 

What should we add to this file? An example from Laravel docs provides a good starting point. We are just going to alter that file a bit to fit our needs, so the final version will look like this:


server {
    listen 80;
    listen [::]:80;
    server_name 203.0.113.0;
    root /var/www/laravel/public;
 
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
 
    index index.php;
 
    charset utf-8;
 
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
 
    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }
 
    error_page 404 /index.php;
 
    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
 
    location ~ /\.(?!well-known).* {
        deny all;
    }
}

The most important things to note here are that NGINX will listen for connections on the default HTTP port 80, the server_name defines an IP address or a domain from which the app will be accessible, and the root defines the document root where the main file, which will process the incoming requests, is stored (in this case, that file is index.php, located in the public folder, which is a default for Laravel apps). Also note that we specified the location of the socket file (php8.3-fpm.sock), which declares what socket is associated with which version of PHP-FPM. If you were using some other version, you would specify it similarly, for example: php8.1-fpm.sock. To save and close the file, just press CTRL+X and then Y and ENTER to confirm.

For this configuration to take effect, we need to create a symbolic link from the sites-available to the sites-enabled directory, like this:


sudo ln -s /etc/nginx/sites-available/laravel.conf /etc/nginx/sites-enabled/

While there, we can also unlink the default configuration that we used when we first installed NGINX:


sudo unlink /etc/nginx/sites-enabled/default

Next, we can test the configuration for any syntax errors by running:


sudo nginx -t

Finally, we need to reload NGINX for the changes to be applied:


sudo systemctl reload nginx

If everything went well, you should be able to access your Laravel app on the public IP address you specified.


Laravel home page

Great job! You may have noticed a Not secure warning in the address bar. That is because we haven't installed an SSL certificate yet. Before we do that, in order to install an SSL certificate, you are going to need a domain name pointed at your server, like example.com. If you don't have a domain name yet, that's fine; you can just test things as they are, using your public IP address, and come back to the SSL section when you are ready to present your website to a wider audience.

We are going to use Let’s Encrypt (Certificate Authority) to obtain a free SSL certificate, which will allow us to enable encrypted HTTPS traffic on our server. Let’s Encrypt uses a piece of software named Certbot, which should automate most of the tasks regarding the process of obtaining an SSL certificate as well as automatic renewal.

To install Certbot, first we need to install snap:


sudo snap install core; sudo snap refresh core

Then we can install Certbot:


sudo snap install --classic certbot

In the end, we will link the certbot command from the snap install directory to our path, so we will be able to run it more easily by just typing certbot


sudo ln -s /snap/bin/certbot /usr/bin/certbot

Before we are able to generate a certificate, we are going to need to make some adjustments to our existing configuration. First, if you remember, we enabled just HTTP traffic on port 80 using UFW. Now we need to enable HTTPS on port 443 as well. We can do that by running this command:


sudo ufw allow 'Nginx Full'

Then delete the previous profile:


sudo ufw delete allow 'Nginx HTTP'

and check the status:


sudo ufw status

Now we should only have 2 profiles active: Nginx Full and 22/tcp for SSH access.

Next, we need to edit our NGINX config file:


sudo nano /etc/nginx/sites-available/laravel.conf 

and replace the public IP address with a domain name. So instead of:


server_name 203.0.113.0;

use:


server_name example.com;

Then perform the 2 commands to verify the config file and to reload NGINX (one by one):


sudo nginx -t

sudo systemctl reload nginx

Then edit Laravel's .env file:


sudo nano /var/www/laravel/.env

and replace again the HTTP version of the APP_URL variable that uses a public IP address http://203.0.113.0 with a HTTPS version of the URL that uses a domain name https://example.com. The end result would look something like this:


APP_URL=https://example.com

Finally, cache the new configuration by using:


cd /var/www/laravel && php artisan config:cache

Now we are ready to install the SSL certificate. Run this command to obtain a certificate for your domain (enter email address when prompted, accept the terms, and choose if you want to share your email address):


sudo certbot --nginx -d example.com

If everything went well, when you visit your domain, you should be able to confirm that the connection is now secure.


HTTPS Secure connection

Certificates are valid only for 90 days and need to be renewed before that time expires. Certbot should renew those certificates automatically for us. It will add a timer that will run twice a day and renew any certificates that are within 30 days of expiration. You can always check the status of the timer by running this command:


sudo systemctl status snap.certbot.renew.service

You can also do a dry run and test the renewal procedure, like this:


sudo certbot renew --dry-run

If there were no errors, you are all good. We have successfully configured NGINX and installed a valid SSL certificate. Now, since most Laravel apps are using a Task Scheduler to schedule automatic execution of Artisan commands, in the next chapter we are going to see how we can add a cron entry that will run the scheduler every minute. Also, we are going to install and configure a Supervisor, which will be responsible for running our Queues.


Configuring the Scheduler and the Supervisor


In order to set up the Scheduler, we just need to add one cron entry on the server that will run the schedule:run command every minute. We can achieve that using this command (if this is your first time adding a cron entry, just press 1 to use nano as the default editor, when prompted, or any other editor you might prefer):


crontab -e

Under all of the comments in that file, just paste this line:


* * * * * cd /var/www/laravel && php artisan schedule:run >> /dev/null 2>&1

Save and exit the file. Restart the service using this command:


sudo systemctl restart cron

Later on, we can check the status of the service like this:


sudo systemctl status cron

That's it regarding cron and running the Task Scheduler

Next, we are going to install and configure Supervisor. Laravel jobs are typically processed by running the queue:work Artisan command on the server. But the command may fail for a variety of reasons. That is why we need a Supervisor to monitor the queue:work process and restart it if something unexpected happens.

We can install Supervisor by using this command:


sudo apt install supervisor

Check the status of the service by running:


sudo systemctl status supervisor

Now, we need to create a configuration file that will instruct the Supervisor how our processes should be monitored. Use this command to create a new file in the appropriate location:


sudo nano /etc/supervisor/conf.d/laravel-worker.conf

Here is an example that you can paste in that file:


[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/laravel/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/laravel/queue-work.log
stopwaitsecs=3600

Something to note here is that we are running a single queue:work process, which is specified with the numprocs directive. You may increase this number if you need to handle a larger load of jobs, and the Supervisor will monitor each of those processes. 

As mentioned in the docs, you should ensure that the value of stopwaitsecs is greater than the number of seconds consumed by your longest running job. Otherwise, the Supervisor may kill the job before it has finished processing. We have also added a path to the queue-work.log file that we can check if anything goes wrong.

For the changes to take effect, we need to reread, update, and start the workers by running these 3 commands (one by one, row by row):


sudo supervisorctl reread

sudo supervisorctl update

sudo supervisorctl start "laravel-worker:*"

If we check the status using the supervisorctl command, we should see that our process is running:


sudo supervisorctl status

If we want, we can also check the log file we defined by running this command:


sudo tail /var/www/laravel/queue-work.log

To find out more about the queue:work command, Supervisor, and creating your own custom commands, you can check out one of my other articles here: Running custom Artisan commands with Supervisor.


Creating a deployment script


What's left is to create a simple shell script with which we can run from our local computer to deploy changes to the server. First, create a deployment folder inside the root folder of your local Laravel installation. Then create two files inside of it: deploy.sh and procedure.sh. Add this to the deploy.sh file:


#!/bin/bash

printf "\nInitiating deploy procedure:\n"

ssh [email protected] bash -s < procedure.sh

printf "\nDeploy completed.\n"

In this file, we are connecting to our server using SSH as we usually would. Additionally, we are passing the output of the procedure script into that SSH session, so we could preview it in our local terminal. The procedure script will contain a group of the commands we will run on each deployment. Add this to the procedure.sh file: 


# Changing the current directory
cd /var/www/laravel

# Putting the app in maintenance mode
php artisan down

# Fetching latest changes from GitHub
git fetch -a
git pull

# Installing Composer
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev

# Restarting PHP-FPM
sudo service php8.3-fpm restart

# Running Laravel specific commands
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan migrate --force
php artisan queue:restart

# Bringing the app back from maintenance mode
php artisan up

As you can see by the comments in that file, we are first going into our app's directory, where we are putting the app in maintenance mode, fetching the code updates from GitHub, installing Composer dependencies, restarting PHP-FPM, running a few Artisan commands (to clear the cache, run the migrations, and restart the queue), and finally bringing the app back from maintenance mode. To run the script, position yourself in the deployment folder (cd deployment) in your local terminal and just run:


sh deploy.sh

When creating a script, make sure that it uses Unix line separators (LF) and that you are running the script from a terminal that can connect to the server using SSH, like Git Bash.

Here is what everything might look like when you are done:


Deploy script

This is, of course, a simple deploy script that can serve as the basis for any other commands you might like to add to your deploy procedure. Feel free to make any changes to the procedure.sh file as you see fit. Keep in mind that using this script, you will experience about 15-30 seconds of downtime, depending on how many procedures you add to the script and how long they take to execute. 

There are ways to achieve zero downtime using symlinks, and there are also tools, packages (Deployer, Envoy), and services (Envoyer, Launchdeck), which can make that easier for you or completely automate it. I won't address that topic in this article, but if you are, for example, running an app that provides services and has paying customers, I suppose you will want to look into those ways to achieve zero downtime; otherwise, those simple shell scripts we created above should probably suffice. Again, it all depends on your current needs.


Optimizations


Before we get to the very end, there is just one thing I would like to mention. In this example, we were using a server with quite limited resources (1 CPU core, 1 GB of RAM), and we were running all of the services needed on that single server (mainly NGINX, PHP-FPM, and MySQL). While that should be enough for small and maybe some medium-sized apps (depending on the setup and optimizations), as your load increases, you might consider servers with higher resources or even using a separate server for MySQL.

If we take into account that in this article we used a server that has 1GB of RAM, MySQL 8 can take quite a bit of that memory. The docs state that "the default configuration is designed to permit a MySQL server to start on a virtual machine that has approximately 512MB of RAM." That would mean that MySQL could take half of the available RAM we have on our example server or even more. 

Beyond that, MySQL has a feature called the Performance Schema that is used for monitoring MySQL server execution at a low level. Another part from the docs nicely explains how the feature is allocating memory: "The Performance Schema dynamically allocates memory incrementally, scaling its memory use to actual server load, instead of allocating required memory during server startup. Once memory is allocated, it is not freed until the server is  restarted." So it is important to track the memory usage on the server, as it can gradually increase.

While there are ways to optimize MySQL and Performance Schema to consume less memory, the simplest way would be to disable Performance Schema altogether on servers with limited memory, like ours. Doing that can significantly decrease the overall MySQL memory usage. There are, of course, some trade-offs if you choose to do that, in terms of the fact that you may not be able to diagnose performance problems or resource usage as easily. There are quite a few discussions on whether that feature should be turned off, but what I generally think is that the chances are that you probably won't be using that feature too much, and it makes sense to disable it on servers with limited resources. Especially when you have the option to turn it on again when you please (with the downside of not having historical data for the period it was turned off). Of course, use cases can vary, so again, it's up to you to decide how you would like to proceed regarding this option.

If you do decide that you want to disable the Performance Schema, here is how you can do it. First, edit the mysqld.cnf file like this:


sudo nano /etc/mysql/my.cnf

Add these values at the end of the file:


[mysqld]
performance_schema = 0 

Now we just need to restart MySQL for the changes to take effect:


sudo service mysql restart

That's it. You can check if the Performance Schema was turned off by logging into MySQL:


sudo mysql

and running this query:


SHOW VARIABLES LIKE 'performance_schema';

If the value is set to OFF, you are good to go. Just to note, in my case, the overall server memory usage decreased by about 250 MB when I disabled this feature.

One other thing you can do (which we won't cover here in detail) to ensure you don't run out of memory when having limited resources is to add some reserved swap space on your server. That disk swap space can be used when you run out of RAM, preventing the OOM (out-of-memory) Killer from shutting down your services. Usually 1 GB of such space should be enough, but feel free to adjust that to your needs. There are some discussions regarding hard and solid-state drives and when and if you should add swap space to an SSD storage device, so make sure to check those if you decide to proceed in that direction.


Conclusion


Congratulations! You made it to the end! This was my longest article so far, and I've tried to include everything you might need to host a fully functional Laravel app on the LEMP stack. Along the way, there were some things I've just mentioned but didn't explain in detail; otherwise, the article would have been a lot longer. Regarding those topics, I've tried to at least point you in the right direction. So, as with almost everything, there are more areas to explore on your own. In any case, I think we covered the most important parts and problems you might encounter when trying to set up your own server using the LEMP stack.

Until next time, take care.

Enjoyed the article? Consider subscribing to my newsletter for future updates.

Goran Popović

Thank you for taking the time to go through this article.

My name is Goran, I'm a software developer that enjoys working with Laravel and exploring its ecosystem.

For more details, check out the about page.

Find me on LinkedIn, Twitter (X) or send me an email.