...

How to Deploy Laravel on a VPS with Nginx, PostgreSQL, Queues, and HTTPS

Martin Klein

Reading time 1 minute

Welcome back!

Before moving on to the server, let’s briefly look at what we will be deploying.

Laravel is a PHP framework for developing web applications. It provides a ready-made project structure and tools for routing, database operations, authentication, queues, background jobs, migrations, caching, and other common backend development tasks.

Simply put, instead of building every PHP web application from scratch, developers get a ready-made foundation and a set of components around which to build the project’s logic. Laravel is used to create APIs, admin panels, online stores, internal services, and other web applications.

However, Laravel itself is not a complete production environment. The application code needs to run somewhere, user requests must be handled, data must be stored, background jobs must be processed, and scheduled commands must be run on schedule. A Laravel deployment on a VPS therefore consists of several separate components, each responsible for a specific part of the workload.

That is exactly the setup we will build today: Nginx will handle HTTP and HTTPS requests, PHP-FPM will execute the PHP code, PostgreSQL will store the data, a queue worker will process background jobs, and the scheduler will run tasks on schedule.

If even one of these layers is configured incorrectly, the application may be deployed on the server but still return 403 Forbidden or 502 Bad Gateway errors, lose background jobs, or fail after a reboot.

In this article, we will walk through the complete process of deploying a small Laravel application on a VPS: preparing the server, installing PHP-FPM and PostgreSQL, deploying the project, and configuring Nginx, queues, the scheduler, and HTTPS. We will then verify that everything starts automatically after a reboot and take a separate look at common permission issues and 403 and 502 errors.

To make the material easier to follow, we will first explain what happens and why each component is needed, then present the commands and configurations in practical sections at the end of the relevant chapters. In other words, we will cover both theory and practice.

How the Laravel Deployment Is Structured

Before proceeding with the installation, it is helpful to look at the overall architecture to get a general idea of how the components relate to one another.

For a small Laravel application hosted on a single VPS, the architecture may look like this:

Two additional processes run alongside the main web application:

Laravel Queue Worker

→ background jobs

Laravel Scheduler

→ scheduled tasks

All these components run on the same VPS but perform different functions. We will therefore configure them separately and gradually connect them into a single system.

Components Involved in the Architecture

Nginx becomes the external entry point.

It accepts user requests, serves static files, and forwards PHP requests to PHP-FPM.

PHP-FPM then executes the Laravel PHP code and returns the result to Nginx.

It is Laravel that handles the application’s business logic: routes, controllers, models, database operations, queues, and other services.

PostgreSQL stores the application’s persistent data.

If Laravel performs resource-intensive or deferred tasks—such as sending emails, generating reports, or processing uploaded files—these operations are best handled through a queue.

A separate queue worker processes them.

The scheduler serves a different purpose: it runs recurring tasks on a schedule, such as cleaning up old records, generating daily reports, or performing maintenance operations.

As a result, the application is not a single process but a small system:

This is why a complete deployment involves more steps than simply copying PHP files.

Why Laravel Cannot Simply Be “Uploaded to a Server”

Many people probably wonder about this. Why is it not that simple?

The reason is that Laravel depends on its environment.

The project code itself does not include a working PHP interpreter, PostgreSQL, Nginx, or Composer.

The application also requires:

  • the correct PHP version;
  • PHP extensions;
  • Composer dependencies;
  • a .env file;
  • an application key;
  • a database connection;
  • writable directories;
  • a configured web server;
  • a separate process for handling queues;
  • a mechanism for running the scheduler.

Even simply copying the project to /var/www does not mean that the application will be accessible in a browser.

For example, Nginx must serve only the public directory, PHP-FPM must have access to the required files, and Laravel must be able to write data to storage and bootstrap/cache.

Proper deployment therefore involves configuring every layer in sequence, rather than simply uploading the project.

What VPS Resources Are Suitable for a Small Application

Resource requirements depend heavily on the application itself, the workload, and the number of background tasks.

However, a fairly modest VPS is sufficient for a small project.

For a very lightweight application, the minimum configuration is approximately 1 vCPU and 1–2 GB of RAM. We will use this configuration in this guide.

This server is suitable for a light workload, but it provides little headroom: Nginx, PHP-FPM, PostgreSQL, and the queue worker must share the available memory.

A more comfortable starting configuration is 2 vCPUs and 2–4 GB of RAM.

This provides more headroom for PHP-FPM, PostgreSQL, and background processes and handles brief workload spikes better.

SSD storage is recommended because the database and Composer are noticeably sensitive to I/O performance.

If the project processes images extensively, runs multiple workers, or executes resource-intensive PostgreSQL queries, resources should be increased based on the actual workload.

Versions Used in This Guide

To make the instructions reproducible, we will define a specific stack. However, this is not the only viable combination: Laravel, PHP, and PostgreSQL support multiple nearby versions, so in a real-world project, you should first check the application’s requirements.

For this article, we will use:

  • Ubuntu 26.04 LTS;
  • Laravel 13;
  • PHP 8.4;
  • PostgreSQL 18;
  • Nginx from the Ubuntu repository;
  • Composer 2.x.

The following table provides a brief overview of compatibility and possible alternatives for each component:

ComponentVersion Used in This ArticleOther Compatible VersionsImportant Considerations
Ubuntu26.04 LTS24.04 LTSPackage names and versions may differ
Laravel13.xAn earlier supported version used by the projectCheck composer.json and the application requirements
PHP8.48.3, 8.5Laravel 13 requires PHP ≥ 8.3; extensions must also be compatible
PostgreSQL1814–17For Laravel, the availability of the pdo_pgsql driver is more important than the specific major version
NginxVersion from the Ubuntu repositoryAnother current, supported versionThe basic Laravel configuration syntax generally remains unchanged
Composer2.xA current release from the 2.x branchThe version must support the project’s PHP version and dependencies

This is enough to define a working environment while retaining flexibility for projects that use nearby component versions.

What to Prepare Before Deployment

Before installing the components, it is best to gather all the required information in advance in a single comprehensive, easy-to-use table:

ComponentPurposeWhat needs to be ready
VPSApplication hostingUbuntu 26.04 LTS, SSH access, and at least 1–2 GB of RAM
LaravelThe web application itselfA completed project and access to its source code
PHP-FPMExecuting PHP codeWe will install PHP 8.4 and the required extensions
ComposerInstalling PHP dependenciesComposer 2.x
PostgreSQLPrimary databaseWe will create a dedicated database and user
NginxHandling HTTP/HTTPS requestsWe will configure a dedicated server block
DomainPublic application addressThe user must have control over the DNS record
Queue workerProcessing background jobsWe will configure a dedicated systemd service
SchedulerRunning Laravel tasks on a scheduleWe will configure it using cron
TLSHTTPSWe will issue a certificate after verifying HTTP

I think it is now clear why you cannot simply deploy Laravel on a VPS. We still have a fairly long way to go.

A knowledgeable reader may ask: why not use Docker, where everything is much simpler? They would be right, but we will deliberately provide in-depth material that will help you understand the internal mechanics of the application components, how they work, and what they use.

The next step is to prepare the VPS itself: update the system, install the essential packages, and check the versions of the components on which Laravel will run.

Preparing the VPS

Why It Is Better to Start with a Clean System

For your first deployment, it is best to use a new VPS with no preconfigured web servers, alternative PHP repositories, or old application configurations.

This makes it easier to understand where each component comes from and which settings affect Laravel.

A server that has been in use for a long time may already have:

  • A different version of PHP;
  • An old PHP-FPM pool;
  • Multiple Nginx configurations;
  • Alternative PostgreSQL repositories;
  • Custom firewall rules;
  • Modified permissions on /var/www;
  • Third-party systemd services.

If you are a beginner, we strongly recommend deploying Laravel on a clean server. If you plan to install it on a server that is already in use, check the services currently running before installation and make sure that the new packages and ports do not conflict with them.

Packages Laravel Requires

Laravel is not installed as a single system package. The server requires several groups of software packages.

The application requires PHP and PHP-FPM. Laravel itself is executed by the PHP interpreter, while PHP-FPM receives requests from Nginx and runs the corresponding PHP code.

PHP extensions are also required. The full set depends on the project, but a typical Laravel application uses extensions for PostgreSQL, UTF-8 string handling, XML, HTTP requests, archives, and other standard operations. We will cover them in detail in the next chapter.

Nginx is required for web access, while persistent data will be stored in PostgreSQL.

Composer is required to install the PHP dependencies specified in composer.json.

It is also useful to have several basic system tools available from the outset:

  • git — for obtaining and updating the source code;
  • curl — for downloading files and testing HTTP requests;
  • unzip — used by some Composer packages;
  • ca-certificates — for validating TLS certificates presented by remote services;
  • tzdata — provides system time zone information.

Some of these packages may already be present in the minimal VPS image. In that case, installing them through the package manager will simply confirm that they are already installed.

Once the basic software stack has been identified, it is worth checking several additional system settings that affect application stability and ease of administration.

Why Configuring the Time, Hostname, and Basic Security Is Important

Accurate system time is required by several parts of the application, including logs, PostgreSQL, queues, the scheduler, TLS certificates, and time-limited tokens.

Time synchronization is usually already enabled on Ubuntu, so you only need to check the current date, time, and time zone. For servers, it is often convenient to use UTC and configure the required time zone at the Laravel level.

The hostname has almost no effect on the application itself, but it makes administration much easier. A server named laravel-prod-01 is easier to distinguish from a test or backup VPS than one with an arbitrary provider-assigned name.

You should also check the basic access settings:

  • Use a dedicated user account with sudo privileges;
  • Use SSH keys;
  • Do not expose unnecessary ports;
  • Do not expose PostgreSQL to the Internet if the database is used only by the local Laravel application.

Extensive hardening is not necessary at this stage. It is enough to ensure that the server reports the correct time, has a clear hostname, and does not expose services that do not require external access.

Server Preparation Commands

First, update the package information and installed packages:

 sudo apt update
sudo apt upgrade -y

Install the basic system utilities:

 sudo apt install -y \
  ca-certificates \
  curl \
  git \
  unzip \
  tzdata

Now install the core components of the future stack:

 sudo apt install -y \
  nginx \
  php \
  php-fpm \
  postgresql \
  postgresql-contrib \
  composer

In Ubuntu 26.04, the php-fpm metapackage installs the system-provided PHP 8.5 release, while the default PostgreSQL package provides PostgreSQL 18. If the project requires PHP 8.4 or 8.3, either version can also be used, but the installation method will depend on the selected repository and the server’s update policy.

Check the main component versions:

 php -v
php-fpm8.5 -v
nginx -v
psql --version
composer --version

Verify that the core services are running:

 sudo systemctl status nginx
sudo systemctl status php8.5-fpm
sudo systemctl status postgresql

For a quick check without detailed output, use:

 systemctl is-active nginx php8.5-fpm postgresql

If all three services are running, the command should return active for each one.

Check the hostname separately:

 hostnamectl

If necessary, assign the server a more descriptive name:

 sudo hostnamectl set-hostname laravel-prod-01

Check the system time status as follows:

 timedatectl

To change the time zone, first list the available options:

 timedatectl list-timezones

Then set the required time zone, such as UTC:

 sudo timedatectl set-timezone UTC

UTC is often convenient for servers because it keeps logs and schedules predictable regardless of the VPS’s physical location. Laravel itself can use its own application time zone if necessary.

Basic VPS Setup Commands

TaskCommand
Update the package indexsudo apt update
Install updatessudo apt upgrade -y 
Install basic utilitiessudo apt install -y ca-certificates curl git unzip tzdata 
Install the core stacksudo apt install -y nginx php php-fpm postgresql postgresql-contrib composer 
Check PHPphp -v 
Check PHP-FPMphp-fpm8.5 -v 
Check Nginxnginx -v 
Check PostgreSQLpsql --version 
Check Composercomposer --version 
Check the servicessystemctl is-active nginx php8.5-fpm postgresql 
Check the hostnamehostnamectl 
Check the system timetimedatectl 

The basic environment is ready.

Installing PHP-FPM and Laravel Extensions

Once the basic VPS setup is complete, you can proceed with PHP. Laravel requires not only the PHP interpreter itself but also PHP-FPM, which Nginx uses to pass PHP requests to the application.

Laravel and its dependencies also rely on a range of PHP extensions. At this stage, you need to set up a complete PHP environment and verify that it meets the project’s requirements.

How Laravel Works with PHP-FPM

Nginx can accept HTTP requests, serve static files, and proxy traffic, but it cannot execute PHP code itself.

When a user opens a Laravel page, the request follows roughly this path:

Nginx determines that the request must be passed to PHP and sends it to PHP-FPM via FastCGI.

PHP-FPM receives the request, runs the application’s PHP code, and returns the result to Nginx. Nginx then sends the completed HTTP response to the user.

Communication between Nginx and PHP-FPM typically uses a Unix socket, for example:

 /run/php/php8.5-fpm.sock

A TCP connection, such as 127.0.0.1:9000, can also be used. However, when PHP-FPM and Nginx run on the same VPS, a Unix socket is generally simpler and does not require opening an additional network port.

Laravel itself does not need to know whether PHP-FPM uses a socket or TCP. From the application’s perspective, it is simply the environment in which the PHP code runs.

This brings us to the next point: the PHP interpreter alone is not enough for Laravel—the project also requires additional extensions.

Commonly Required PHP Extensions

The required extensions depend on the specific Laravel application and the Composer packages it uses.

A typical project using PostgreSQL will usually require:

  • pdo_pgsql — PostgreSQL connectivity via PDO;
  • mbstring — multibyte string handling;
  • xml — XML processing and support for dependencies that use DOM;
  • curl — HTTP requests to external services;
  • zip — ZIP archive handling;
  • bcmath — arbitrary-precision arithmetic;
  • intl — localization and formatting of dates, numbers, and strings;
  • ctype — character type checking;
  • fileinfo — file type detection.

Some extensions may already be included in the base PHP distribution or installed with other packages.

The exact set should be determined based on the project’s requirements. Composer usually reports immediately if an application is missing a specific extension, such as ext-intl or ext-pdo_pgsql.

Rather than installing every available PHP module “just in case,” it is better to install a core set and add only the extensions actually required by the application and its dependencies.

After installing the modules, the next step is to check the PHP-FPM configuration itself.

What to check in php.ini and the PHP-FPM pool configuration

The main PHP settings are stored in php.ini, but for a web application, the PHP-FPM configuration is particularly important.

The CLI and PHP-FPM may use different php.ini files. Therefore, php -i may report one value while the application running through Nginx uses another.

For PHP 8.5, the path to the FPM configuration file typically looks like this:

 /etc/php/8.5/fpm/php.ini

PHP-FPM pool configurations are stored separately, for example:

 /etc/php/8.5/fpm/pool.d/www.conf

The pool configuration defines which user runs the worker processes, where the socket is located, and how PHP-FPM manages child processes.

A standard Ubuntu installation typically uses the www-data user. This will become particularly important later when configuring permissions for storage and bootstrap/cache.

Several parameters in php.ini are typically worth checking:

  • memory_limit — the maximum amount of memory available to a single PHP process;
  • upload_max_filesize — the maximum size of an uploaded file;
  • post_max_size — the maximum size of a POST request;
  • max_execution_time — the maximum execution time for a web request;
  • date.timezone — the PHP time zone, if configured at this level.

However, there is no need to change all these values immediately.

The default settings are often sufficient for a small Laravel application. Limits should be adjusted based on the project’s actual requirements—for example, if the application accepts large files or performs resource-intensive operations.

It is also important to check where PHP-FPM listens for requests from Nginx. In the pool configuration, this is defined by the listen parameter.

For example:

 listen = /run/php/php8.5-fpm.sock

The same path must later be specified in the Nginx configuration. If Nginx is configured to use a different socket, this will typically result in a 502 Bad Gateway error.

Therefore, before configuring the web server, verify three things: the required extensions are installed, PHP-FPM starts successfully, and its socket path is known.

Commands for Installing and Verifying PHP-FPM

If PHP and PHP-FPM were installed in the previous step, all that remains is to add the extensions required by Laravel and PostgreSQL:

 sudo apt install -y \
  php-pgsql \
  php-mbstring \
  php-xml \
  php-curl \
  php-zip \
  php-bcmath \
  php-intl

To check the installed PHP version:

 php -v

To view the loaded extensions:

 php -m

If necessary, you can check for specific modules:

 php -m | grep -E 'pdo_pgsql|mbstring|xml|curl|zip|bcmath|intl'

To check the status of PHP-FPM:

 sudo systemctl status php8.5-fpm

For a quicker check:

 systemctl is-active php8.5-fpm

Now verify that the socket exists:

 ls -l /run/php/php8.5-fpm.sock

To view the current PHP CLI configuration:

 php --ini

It is more convenient to check the PHP-FPM configuration path separately:

 php-fpm8.5 -i | grep "Loaded Configuration File"

After modifying php.ini or the PHP-FPM pool configuration, restart the service:

 sudo systemctl restart php8.5-fpm

Next, as usual, we have a handy table. It will help organize the information covered above.

Basic PHP-FPM Commands

TaskPurposeCommand
Install the core Laravel extensionsAdd support for PostgreSQL, string handling, XML, HTTP requests, ZIP archives, and other featuressudo apt install -y php-pgsql php-mbstring php-xml php-curl php-zip php-bcmath php-intl
Check the PHP versionEnsure that the required PHP version branch is in usephp -v
List loaded modulesCheck which extensions are available in PHPphp -m
Check the required extensionsQuickly verify that the core Laravel modules are installedphp -m | grep -E 'pdo_pgsql|mbstring|xml|curl|zip|bcmath|intl'
Check PHP-FPMEnsure that the service is runningsystemctl is-active php8.5-fpm
Check the socketCheck whether the socket is available for Nginx to connect tols -l /run/php/php8.5-fpm.sock
View the CLI php.iniDetermine which configuration file PHP uses in the terminalphp --ini
View the FPM php.iniCheck the configuration used by PHP-FPMphp-fpm8.5 -i | grep "Loaded Configuration File"
Restart PHP-FPMApply the configuration changessudo systemctl restart php8.5-fpm

The PHP environment is now ready to run Laravel code. Next, we will configure PostgreSQL: create a dedicated database and application user, and prepare the connection settings for .env.

Configuring PostgreSQL for Laravel

How Laravel Connects to PostgreSQL

Laravel does not communicate directly with PostgreSQL using its own protocol. The pdo_pgsql PHP driver, which we installed in the previous step, acts as an intermediary between the application and the database.

For clarity, the connection flow looks like this:

The application obtains its connection parameters from the Laravel configuration. In production, they are typically defined in the .env file and then used by the configuration in config/database.php.

To connect to PostgreSQL, Laravel needs the following:

  • Database server address;
  • Port;
  • Database name;
  • Username;
  • Password.

If PostgreSQL is running on the same VPS, the connection can remain local. This is simpler and more secure than allowing external connections to the database when they are not needed.

The next step is to separate the application account from the PostgreSQL administrator account.

Why Create a Separate User and Database

After PostgreSQL is installed, the postgres administrative system role is already available. Laravel should not use it directly.

The application should have a dedicated role, such as laravel_user, and a separate database:

 laravel_db

This gives Laravel access only to its own database, while the administrative account remains reserved for PostgreSQL maintenance.

This separation offers several benefits:

  • The application does not run with PostgreSQL administrator privileges;
  • Permissions are easier to restrict;
  • Laravel credentials can be changed independently;
  • Multiple applications on the same server can be isolated from one another;
  • An error or vulnerability in one project does not automatically give it access to all other databases.

For a small application, a dedicated user generally only needs to own its database and be able to create the objects required by Laravel migrations.

After creating the database, the remaining step is to add these parameters to the application’s configuration.

Key Considerations for DB_HOST, DB_PORT, DB_DATABASE, and DB_USERNAME

Laravel database connection settings are typically stored in the .env file.

For our setup, they will look something like this:

 DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel_db
DB_USERNAME=laravel_user
DB_PASSWORD=strong_password

DB_CONNECTION=pgsql tells Laravel to use PostgreSQL.

DB_HOST specifies the database server address. Because PostgreSQL is running on the same VPS, you can use 127.0.0.1 here.

This is an important distinction: localhost and 127.0.0.1 do not always use exactly the same connection method. With some clients, localhost may result in a Unix socket connection, while 127.0.0.1 explicitly specifies a TCP connection.

For this guide, we will use 127.0.0.1 to make the connection behavior clearer:

Laravel → 127.0.0.1:5432 → PostgreSQL

DB_PORT specifies the PostgreSQL port. The default is 5432.

DB_DATABASE contains the name of the application’s database, while DB_USERNAME specifies the role Laravel will use to connect to it.

The password is set using DB_PASSWORD. It should not be reused for SSH, the provider’s control panel, or other services.

Important. The .env file itself must not be added to a public Git repository either: in addition to the PostgreSQL credentials, it will later contain other application secrets.

Once these settings have been defined, you can proceed to the practical steps and create the database.

Commands for Creating the Database and User

Open PostgreSQL as the postgres system user:

 sudo -u postgres psql

Create a dedicated Laravel role with a password:

 CREATE USER laravel_user WITH PASSWORD 'replace_with_strong_password';

Now create the database and assign laravel_user as its owner:

 CREATE DATABASE laravel_db
OWNER laravel_user;

Check the roles:

 \du

Then check the list of databases:

 \l

Then exit psql:

 \q

Now test the connection using the credentials the application will use to access PostgreSQL rather than the administrative account:

 psql -h 127.0.0.1 -p 5432 -U laravel_user -d laravel_db

PostgreSQL will prompt for the laravel_user password.

If the connection is successful, you can also check the current database and user:

 SELECT current_database(), current_user;

The expected output should match the values you created:

current_databasecurrent_user
laravel_dblaravel_user

After verifying the connection, exit with the \q command.

Verifying PostgreSQL

TaskPurposeCommand
Open PostgreSQL as an administratorCreate the application role and databasesudo -u postgres psql
Create a Laravel userAvoid using an administrative role in the applicationCREATE USER laravel_user WITH PASSWORD '...';
Create the databaseProvide dedicated storage for the applicationCREATE DATABASE laravel_db OWNER laravel_user;
List rolesVerify that the user was created\du
List databasesVerify that laravel_db was created\l
Test the application connectionEnsure that the credentials workpsql -h 127.0.0.1 -p 5432 -U laravel_user -d laravel_db
Check the active database and userEnsure that the connection is not using the wrong database or roleSELECT current_database(), current_user;

PostgreSQL is now ready to accept connections from the application. Let’s move on.

Deploying the Laravel Application on the Server

PHP and PostgreSQL are ready, so we can now move on to the application itself. At this stage, we will place the code in a separate directory, install the Composer dependencies, prepare the .env file, and configure permissions so that Laravel can write data without making the entire project writable unnecessarily.

Where to Store the Code

On Linux, web applications typically use the /var/www directory.

For our project, we can create a separate directory:

 /var/www/laravel-app

The entire Laravel project will be stored inside it:

A separate directory keeps the application code from being mixed with Nginx, PostgreSQL, or systemd configuration files.

If multiple projects are later hosted on the same VPS, the directory structure might look like this:

The code itself can be obtained via Git, uploaded as an archive, or delivered through CI/CD. This guide uses Git as the most straightforward option.

Before configuring Nginx, it is important to determine exactly which application directory should be accessible from a browser.

Why the document root must point to public

Laravel deliberately separates public files from the internal parts of the project.

The public directory contains the application’s entry point:

 public/index.php

It also typically contains CSS, JavaScript, images, and other assets that can be served directly to users.

Everything else must remain outside the document root:

  • .env
  • config/
  • vendor/
  • storage/
  • database/

Therefore, Nginx must later be configured to use:

 /var/www/laravel-app/public

rather than:

 /var/www/laravel-app

If the entire project directory is set as the root, the web server could potentially serve files that should not be externally accessible.

The directory structure should therefore look like this:

Laravel then uses index.php to access the project’s internal directories as needed.

Another important consideration is that not all parts of the project should have the same permissions.

If PHP-FPM is allowed to modify the entire application directory, a vulnerability in the application could potentially allow PHP code, configuration, or other project files to be modified. Conversely, if the permissions are too restrictive, Laravel will be unable to write logs, cache files, and other runtime data.

Permissions should therefore be separated: the code should generally remain read-only, while write access should be granted only where it is actually required—primarily in storage and bootstrap/cache.

How .env, storage, and bootstrap/cache work

These three components serve entirely different purposes.

The .env file stores environment-specific settings for a particular server. It contains the database configuration, application URL, operating mode, queue driver, and other settings that should not be hard-coded in the source code.

For example:

 APP_ENV=production
APP_DEBUG=false
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel_db
DB_USERNAME=laravel_user
DB_PASSWORD=replace_with_strong_password

The storage directory is used while the application is running. Laravel stores the following there:

  • Logs;
  • Cache files;
  • Sessions when using the file driver;
  • Temporary data;
  • Compiled views;
  • User files, if the application is configured accordingly.

Laravel uses bootstrap/cache for cached configuration, routes, and other internal files.

The storage and bootstrap/cache directories must be writable by the user running PHP-FPM.

The rest of the project generally does not need to remain writable by the web process.

This raises the question of how to configure Linux permissions correctly.

Why Proper Permissions Matter More Than chmod 777

One of the most common attempts to quickly fix a Permission denied error looks like this:

 chmod -R 777

This sometimes resolves the issue because it grants write access to every user on the system.

However, it also eliminates a proper access control model.

In production, it is better to separate two roles:

  • The user who deploys and updates the code;
  • The PHP-FPM user that runs the application.

On Ubuntu, PHP-FPM typically runs as www-data.

This user does not need permission to modify the entire Laravel project. For the application to work properly, it only needs write access to:

  • storage/
  • bootstrap/cache/

For example, the code itself can be owned by an administrative user, while the directories that Laravel writes to at runtime can belong to the www-data group.

This approach resolves permission issues in a targeted way instead of making the entire project writable.

Another important point is that file permissions and directory permissions are not the same. For PHP-FPM to create a new file in storage, it needs access not only to the file itself but also to its parent directory.

Laravel permission issues should therefore be diagnosed by examining the owner, group, and permissions of the relevant directory tree rather than attempting to fix them with a single global chmod command.

Now we can put all of this into practice.

Hands-on: Deploying the Project and Preparing Laravel

Navigate to /var/www:

 cd /var/www

Clone the project from the Git repository:

 sudo git clone https://example.com/your-project.git laravel-app

Assign ownership of the project to the current user so that subsequent commands do not need to be run as root:

 sudo chown -R "$USER":"$(id -gn)" /var/www/laravel-app

Navigate to the application directory:

 cd /var/www/laravel-app

Install the production dependencies with Composer:

 composer install \
  --no-dev \
  --optimize-autoloader \
  --no-interaction

If the repository contains a sample environment file named .env.example, use it to create the working .env file:

 cp .env.example .env

Now open the file and specify the settings for the server:

 nano .env

Also verify the following settings:

 APP_ENV=production
APP_DEBUG=false
APP_URL=https://app.example.com
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel_db
DB_USERNAME=laravel_user
DB_PASSWORD=replace_with_strong_password

Generate the Laravel application key:

 php artisan key:generate

If the database is still empty and the application uses Laravel migrations, run:

 php artisan migrate --force

The --force option is required to run migrations in a production environment without interactive confirmation. Before applying migrations to an existing production database, they should, of course, be tested beforehand.

Now configure permissions for Laravel’s writable directories.

Assign them to the www-data group:

 sudo chgrp -R www-data storage bootstrap/cache

Grant the owner and group read and write permissions and directory access:

 sudo chmod -R ug+rwX storage bootstrap/cache

The .env file itself can have more restrictive permissions:

 chmod 600 .env

Verify ownership and permissions:

 ls -ld \
  /var/www/laravel-app \
  storage \
  bootstrap/cache

Check .env separately:

 ls -l .env

After that, run a few basic Laravel checks:

 php artisan about

Verify the database connection through Laravel:

 php artisan migrate:status

If the application loads successfully, can connect to PostgreSQL, and encounters no write errors in storage, the basic project setup is complete.

Essential Laravel Setup Commands

TaskPurposeCommand
Clone the projectPlace the source code on the VPSsudo git clone https://example.com/your-project.git laravel-app 
Transfer ownership of the code to the current userAvoid routinely working on the project as rootsudo chown -R "$USER":"$(id -gn)" /var/www/laravel-app 
Install dependenciesInstall the application’s PHP librariescomposer install --no-dev --optimize-autoloader --no-interaction 
Create the .env file Prepare the server configurationcp .env.example .env 
Generate APP_KEY Create the Laravel encryption keyphp artisan key:generate 
Run migrationsSet up the PostgreSQL schemaphp artisan migrate --force 
Assign the working directories to the PHP-FPM groupAllow Laravel to write runtime filessudo chgrp -R www-data storage bootstrap/cache 
Grant write permissionsAllow the owner and www-data to work with the runtime directoriessudo chmod -R ug+rwX storage bootstrap/cache 
Restrict access to .env Protect the configuration and credentialschmod 600 .env 
Verify LaravelEnsure the application starts successfullyphp artisan about 
Check the databaseEnsure Laravel can connect to PostgreSQLphp artisan migrate:status 

Next, we’ll move on to configuration.

Configuring Nginx for Laravel

The application is already on the server, its dependencies are installed, and Laravel can connect to PostgreSQL. However, it is not yet properly accessible from a browser: a web server is needed to accept user requests and pass PHP scripts to PHP-FPM for execution.

Nginx will serve this role. It is especially important to set the correct document root, configure PHP request handling, and prevent the project’s internal files from being publicly accessible.

How Nginx Passes PHP Requests to PHP-FPM

Nginx does not execute PHP code itself. When a request to Laravel arrives, Nginx determines whether to return a static file or pass the request to the application for processing.

PHP requests are handled through FastCGI. In this setup, Nginx connects to PHP-FPM through a Unix socket:

 /run/php/php8.5-fpm.sock

It looks like this:

In the Nginx configuration, the connection to PHP-FPM is specified by the fastcgi_pass directive.

It answers a simple question: where Nginx should send the PHP request after deciding not to handle it itself.

This setup uses a Unix socket:

 fastcgi_pass unix:/run/php/php8.5-fpm.sock;

In other words, Nginx does not run PHP directly. Instead, it passes the request to an already running PHP-FPM process through a dedicated local socket file.

Another option is to pass requests over TCP, for example to 127.0.0.1:9000. However, if Nginx and PHP-FPM are running on the same VPS, a Unix socket is generally more convenient: it does not require a separate network port, and the connection remains local.

The path specified in fastcgi_pass must match the actual PHP-FPM socket.

For example, if PHP-FPM is listening on:

 /run/php/php8.5-fpm.sock

but Nginx is configured to use:

 /run/php/php8.4-fpm.sock

Nginx will be unable to connect to PHP-FPM. As a result, the user will typically see a 502 Bad Gateway error.

In addition to fastcgi_pass, the Nginx PHP block contains several other important directives.

The include fastcgi_params; directive loads the standard set of FastCGI parameters that Nginx passes to PHP-FPM, including the request method, URI, query string, and other HTTP request data.

fastcgi_param SCRIPT_FILENAME tells PHP-FPM which PHP file to execute. Without the correct path, PHP-FPM may receive the request but not know which script to run.

For example:

 fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;

For Laravel, this ultimately results in the following file being executed:

 /var/www/laravel-app/public/index.php

The fastcgi_param DOCUMENT_ROOT directive also passes information about the site’s root directory to PHP.

In other words, roughly the following occurs inside the PHP block:

But even with PHP-FPM configured correctly, Nginx still needs to determine which requests should be treated as PHP requests and which file to execute for a URL like /products/25.

This is where Laravel’s key behavior comes into play: nearly all dynamic requests must pass through public/index.php.

Why All Requests Must Go Through public/index.php

Laravel uses a single entry point:

 /var/www/laravel-app/public/index.php

Users can access /login, /profile, /api/users, or any other route even though no corresponding physical PHP files exist in the public directory.

Instead, the request is passed to index.php, after which Laravel determines the appropriate route and invokes the required controller.

For example:

Nginx can serve existing static files—such as images, CSS, or JavaScript—directly without invoking PHP.

This is why the site’s document root must point to:

 /var/www/laravel-app/public

This exposes only the public part of the project, while Laravel’s internal structure remains outside the web root.

To handle both physical files and Laravel’s virtual routes, Nginx uses try_files.

What try_files Does

The try_files directive determines how to handle an incoming URL.

For Laravel, the following logic is typically used:

 try_files $uri $uri/ /index.php?$query_string;

This can be read as follows:

  1. First, check whether a file exists at the specified path.
  2. Then, check whether the corresponding directory exists.
  3. If neither is found, pass the request to index.php while preserving the query string parameters.

For example, suppose a user requests:

 /css/app.css

If this file exists in public/css, Nginx serves it directly.

However, the request:

 /users/15

does not correspond to a physical file, so it is forwarded to:

 /index.php

and then passed to the Laravel router.

Without this configuration, Nginx would try to find an actual file at /users/15 and return 404 Not Found, even if the route exists in Laravel.

In this way, try_files bridges Nginx’s standard file structure and the application’s routing system.

However, it is important not only to route permitted requests correctly, but also to prevent direct access to internal project files.

Directories That Must Not Be Served Directly

If the document root already points to public, most of Laravel’s internal files automatically remain outside the user-accessible area.

For example:

  • /var/www/laravel-app/.env
  • /var/www/laravel-app/config/
  • /var/www/laravel-app/vendor/
  • /var/www/laravel-app/database/
  • /var/www/laravel-app/storage/

These are not located inside /public and therefore should not be served by Nginx as regular files.

This is one reason why the document root must not be set to /var/www/laravel-app.

Exposing .env is particularly dangerous. It may contain:

  • the PostgreSQL password;
  • APP_KEY;
  • SMTP credentials;
  • API tokens;
  • third-party service keys;
  • queue and cache settings.

The vendor directory should not be exposed as a separate public directory either: it contains the code for Composer dependencies, which the application loads itself.

Whether content in storage can be exposed depends on the purpose of the specific subdirectory. For example, Laravel may store logs and private files there that must not be served directly via a URL.

If the application requires publicly accessible uploaded files, Laravel typically uses a separate public/storage path linked to the appropriate directory via a symbolic link. This does not make the entire internal storage directory public.

These rules can now be combined into a complete Nginx configuration.

Hands-on: Creating an Nginx Configuration

Create a separate server block:

 sudo nano /etc/nginx/sites-available/laravel-app

For the initial launch, we will use HTTP. We will configure HTTPS later, after confirming that the application loads correctly.

Example configuration:

 server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;
    root /var/www/laravel-app/public;
    index index.php index.html;
    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;
    }
    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
    }
    location ~ /\.(?!well-known).* {
        deny all;
    }
    access_log /var/log/nginx/laravel-app.access.log;
    error_log /var/log/nginx/laravel-app.error.log;
}

Replace:

 app.example.com

with the application’s actual domain.

The following line:

 root /var/www/laravel-app/public;

keeps the document root inside the public directory.

The following directive:

 fastcgi_pass unix:/run/php/php8.5-fpm.sock;

connects Nginx to PHP-FPM.

This separate rule:

 location ~ /\.(?!well-known).* {
    deny all;
}

blocks direct access to hidden files and directories while making an exception for .well-known, which will be needed later for domain validation and TLS certificate issuance.

Now enable the configuration using a symbolic link:

 sudo ln -s /etc/nginx/sites-available/laravel-app \
  /etc/nginx/sites-enabled/laravel-app

If the default Nginx site is no longer needed, you can disable it:

 sudo rm -f /etc/nginx/sites-enabled/default

Before applying the configuration, be sure to check its syntax:

 sudo nginx -t

If the syntax check succeeds, messages similar to the following will appear:

 syntax is ok
test is successful

Only then should you reload the configuration:

 sudo systemctl reload nginx

Check the status of Nginx:

 systemctl is-active nginx

If the domain’s DNS already points to the VPS, you can test the application in a browser or with curl:

 curl -I http://app.example.com

Until HTTPS is configured, a standard HTTP response from the application is the expected result.

If the domain has not yet been configured, you can temporarily test the server using its IP address by adding an appropriate server_name or a local entry to hosts. However, for subsequent TLS configuration, it is better to use a real domain.

Basic Nginx Configuration Commands

TaskPurposeCommand
Create a configurationDefine a separate virtual host for Laravelsudo nano /etc/nginx/sites-available/laravel-app 
Enable the siteEnable the configuration in Nginxsudo ln -s /etc/nginx/sites-available/laravel-app /etc/nginx/sites-enabled/laravel-app 
Disable the default sitePrevent conflicts with the default configurationsudo rm -f /etc/nginx/sites-enabled/default 
Check the syntaxDetect errors before applying the configurationsudo nginx -t 
Reload NginxApply changes without fully stopping the servicesudo systemctl reload nginx 
Check the statusVerify that Nginx is runningsystemctl is-active nginx 
Check HTTPVerify that the domain responds through Nginxcurl -I http://app.example.com 
View site errorsIdentify Nginx or PHP-FPM issuessudo tail -f /var/log/nginx/laravel-app.error.log 

At this point, the Laravel installation is nearly complete. The web stack is now in place: Nginx accepts HTTP requests, passes dynamic requests to PHP-FPM for processing, and Laravel can access PostgreSQL.

The next step is to finalize the Laravel configuration: review the .env file, disable debug mode, and prepare the configuration, route, and view caches.

Configuring Environment Variables and the Laravel Cache

Critical .env Parameters for Production

The .env file stores environment-specific settings. It typically contains parameters that differ between local development, staging, and production.

For production, start by checking the following values:

 APP_ENV=production
APP_DEBUG=false
APP_URL=https://app.example.com

APP_ENV specifies the environment type. For a production server, this is typically set to production.

APP_DEBUG controls detailed error output. It must be disabled in production, as discussed in more detail below.

APP_URL specifies the application’s primary URL. Laravel and certain packages may use it to generate absolute links, callback URLs, and other addresses.

Next, configure the PostgreSQL parameters:

 DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel_db
DB_USERNAME=laravel_user
DB_PASSWORD=replace_with_strong_password

These values must match the database and user created earlier.

If the application uses queues, you should also check the driver:

 QUEUE_CONNECTION=database

The specific value depends on the project. In addition to a database, Laravel can use Redis, Amazon SQS, and other backends. We will configure the queue worker later in this article, so for now, make sure the selected driver is available and properly configured.

The same applies to sessions and caching. For example:

 SESSION_DRIVER=database
CACHE_STORE=database

Alternatively, use whichever option the project requires.

Do not copy these values blindly. If you specify database without creating the required tables, the application will encounter errors at runtime.

Therefore, .env is best viewed as a description of which external components and drivers this particular Laravel instance should use.

After checking these parameters, it is worth taking a closer look at APP_DEBUG.

Why APP_DEBUG=false Is Essential

Detailed error messages are useful during development. Laravel can display the stack trace, file, line number, request parameters, and other technical information that helps identify issues quickly.

In production, however, this becomes a security risk.

If you leave this setting as follows:

 APP_DEBUG=true

an ordinary user may see information about the application’s internal workings when an error occurs.

For example:

  • File paths on the server;
  • Class and method names;
  • Parts of SQL queries;
  • Configuration values;
  • The application structure;
  • Information about installed packages.

Even if passwords are not exposed directly, this information makes it significantly easier for an attacker to analyze the application.

The production configuration should therefore include:

 APP_ENV=production
APP_DEBUG=false

Users will then receive a standard error page, while the details remain in the Laravel logs.

Disabling debug mode does not hinder troubleshooting: errors can still be investigated in storage/logs, the Nginx journal, and the PHP-FPM logs.

After configuring .env, there is another important consideration: Laravel can cache part of its configuration so that it does not have to rebuild it for every request.

Differences between config cache, route cache, and view cache

Laravel uses several types of cache, each serving a different purpose.

Config cache combines the application’s configuration files into an optimized cache.

Once it has been created, Laravel no longer needs to reload all the files from config/ for every request.

Conceptually:

Therefore, after environment variables are changed, the existing config cache may still contain the previous values.

Route cache stores the prepared route description.

If an application has many routes, Laravel does not need to rebuild them from the source files each time.

The process works roughly as follows:

This cache is particularly useful in production, where routes typically do not change between requests.

View cache applies to Blade templates. Laravel compiles Blade into standard PHP code to avoid processing the template from scratch on every request.

In other words:

These three mechanisms serve different purposes, so clearing one cache does not necessarily affect the others.

To avoid having to remember each command separately, Laravel also provides a way to optimize the application as a whole. Even so, it is useful to understand exactly what is stored in the cache and why an old state may sometimes continue to be used after changes to .env or routes.

When to Clear the Cache After Changes

Laravel’s cache is useful as long as the application configuration remains unchanged.

After modifying .env, it is especially important to refresh the config cache. Otherwise, the application may continue using the previously cached value.

For example, an administrator changes:

 DB_HOST=127.0.0.1

to point to a different PostgreSQL server, but Laravel continues connecting to the old address.

The issue may not be with .env, but with the existing config cache.

The same applies to routes. If routes are changed after the route cache has been created, the application may continue using the old routing table until the cache is refreshed.

The view cache is usually less critical: Laravel can recompile templates, but clearing it can still be useful when troubleshooting display issues.

In practice, it is useful to follow these simple guidelines:

  • If .env or files in config/ have changed, refresh the config cache;
  • If routes have changed, refresh the route cache;
  • If Blade behaves unexpectedly, clear the view cache;
  • After a major deployment, clear the old optimization files and rebuild them.

This is especially important for automated deployments. If the code has been updated but the old cache remains, the application may end up in an inconsistent state: some data is already up to date, while parts of the configuration still belong to the previous release.

You can now apply the production settings and build the cache.

Laravel Configuration and Caching Commands

First, verify that Laravel is running in production mode:

 php artisan about

After modifying the .env file, clear the previously cached configuration:

 php artisan config:clear

Then rebuild the configuration cache:

 php artisan config:cache

Routes can be cached separately:

 php artisan route:cache

Blade views can also be precompiled:

 php artisan view:cache

To clear only the route cache:

 php artisan route:clear

To clear the view cache:

 php artisan view:clear

To clear the application’s main optimization caches all at once, use:

 php artisan optimize:clear

You can then rebuild the production caches with a single command:

 php artisan optimize

Before using the route cache, make sure the application’s routes are compatible with this mechanism. If the command fails, address the cause reported by Laravel rather than ignoring the result.

Key Laravel Cache Commands

TaskPurposeCommand
Check the Laravel environmentEnsure that the application is using the expected configurationphp artisan about 
Clear the configuration cacheRemove cached values from the old configurationphp artisan config:clear 
Create the configuration cacheSpeed up production configuration loadingphp artisan config:cache 
Create the route cacheCache the compiled route tablephp artisan route:cache 
Clear the route cacheApply route changesphp artisan route:clear 
Create the view cachePrecompile Blade templatesphp artisan view:cache 
Clear the view cacheRemove compiled viewsphp artisan view:clear 
Clear optimization cachesRemove stale cached data before deployment or troubleshootingphp artisan optimize:clear 
Optimize the applicationBuild production caches with a single commandphp artisan optimize 

After this, the Laravel production configuration is in order: debug mode is disabled, the .env values have been verified, and the configuration and other frequently used data have been prepared for continuous operation.

However, the application is not limited to handling HTTP requests. The next layer consists of background jobs that must run even after the user has received a response. To handle them, we will configure a Laravel queue worker and run it as a separate, continuously running service.

Starting the queue worker

Why Laravel Needs Queues

A queue separates slow tasks, or tasks that are not required for the current request, from the response returned to the user.

For example, after a user registers, the application can:

  • save the user to the database;
  • immediately return a successful response;
  • queue the welcome email for delivery separately.

The main workflow then looks like this:

And independently of it:

Queues are ideal for sending emails and notifications, generating reports, processing images, importing data, and handling other operations that do not need to be completed before the HTTP response is generated.

This does more than improve interface responsiveness. If an external service is temporarily unavailable, Laravel can retry the job later instead of immediately failing the user’s request.

However, the queue itself is only a place where jobs are stored. To process them, you need a separate process that continuously retrieves jobs from the queue and runs them.

For a quick test, you can start this worker manually from the terminal. However, this is not sufficient for continuous operation: the process must not depend on an administrator’s active SSH session.

Why You Should Not Run a Queue Worker Directly over SSH

For testing, you can start a worker manually from the project directory.

The problem is that such a process is tied to the current shell session.

If you close the SSH connection, exit the terminal, or restart the server, the worker will stop. New jobs will continue to enter the queue, but there will be no worker to process them.

This can look rather strange: the website remains accessible, and users can create database records, but emails are not sent, reports are not generated, and the number of pending jobs gradually increases.

Therefore, a production worker must run as a managed background process.

We need a mechanism that can:

  • Start the worker when the system starts;
  • Restart it after a failure;
  • Track the process status;
  • Collect logs;
  • Gracefully stop and restart the worker when the application is updated.

systemd or Supervisor is typically used for this purpose.

Why systemd or Supervisor Is a Better Choice for Production

Supervisor has long been used to manage Laravel queue workers and makes it easy to maintain multiple processes.

systemd handles the same basic task using native Linux functionality. On modern Ubuntu systems, it already manages Nginx, PHP-FPM, PostgreSQL, and many other services.

For our setup, a dedicated systemd unit is simpler because there is no need to install another process manager.

This gives us a consistent approach:

systemd can automatically start the worker after the VPS reboots, restart it after a crash, and display its logs through journalctl.

Supervisor remains a viable alternative. It is particularly useful when you need to manage a large number of identical workers or when the project already uses Supervisor in its existing infrastructure.

In this article, we will use systemd.

However, there is another important consideration with a long-running worker: it does not reload the entire Laravel project before each new job.

How a Worker Responds to Application Updates

A queue worker is a long-running PHP process.

It loads Laravel at startup and then continues retrieving new jobs from the queue. Therefore, after a code update, an existing worker may continue using the previous version of the application for some time.

For example, a deployment modifies a Job class:

This is one reason why workers must be restarted gracefully after a deployment.

Laravel allows you to instruct workers to finish their current jobs and then exit. This approach is preferable to forcibly terminating a process in the middle of a job.

After that, systemd starts a new worker with the updated code and configuration.

It is also important to keep the configuration cache in mind. If the .env file or files in config/ have changed along with the code, update Laravel’s configuration cache first, and then restart the worker.

A typical deployment sequence therefore looks like this:

The worker can now be configured as a persistent systemd service.

Hands-on: Creating a systemd service for a queue worker

For this example, assume that the queue has already been configured via QUEUE_CONNECTION in .env and that the application is located at:

 /var/www/laravel-app

Create a dedicated unit:

 sudo nano /etc/systemd/system/laravel-queue.service

Add the following configuration:

 [Unit]
Description=Laravel Queue Worker
After=network.target postgresql.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/laravel-app
ExecStart=/usr/bin/php artisan queue:work \
  --sleep=3 \
  --tries=3 \
  --timeout=90
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

User and Group specify the Linux user and group under which the worker runs. This configuration uses www-data, as does PHP-FPM.

WorkingDirectory specifies the Laravel directory, ensuring that the artisan command runs within the correct project.

The main directive is:

 ExecStart=/usr/bin/php artisan queue:work

This starts a persistent Laravel worker.

The --sleep=3 option sets the interval between queue checks when there are no new jobs.

--tries=3 limits the number of attempts to process a job.

--timeout=90 sets the maximum execution time for a single job. If a job can legitimately take longer, increase this value as appropriate for the application.

Restart=always instructs systemd to restart the worker after it exits, whether because of a failure or a normal shutdown, such as after php artisan queue:restart. The RestartSec=5 option adds a short delay before the restart.

After saving the file, reload the unit files:

 sudo systemctl daemon-reload

Enable automatic startup and start the worker immediately:

 sudo systemctl enable --now laravel-queue

Check the status:

 sudo systemctl status laravel-queue

For a quick check:

 systemctl is-active laravel-queue

You can view the worker logs in the journal:

 sudo journalctl -u laravel-queue

Or follow them in real time:

 sudo journalctl -u laravel-queue -f

After updating the Laravel application, you can gracefully restart the worker with:

 php artisan queue:restart

This command does not interrupt a job in progress. The worker completes the current job and then stops.

For this reason, it is best to use the following settings in the systemd unit:

 Restart=always
RestartSec=5

systemd detects that the process has exited and automatically starts a new worker with the updated code and configuration.

This results in the following sequence:

Laravel must also have a working cache driver because the queue:restart signal is stored in the application cache.

Main queue worker commands

TaskPurposeCommand
Create a systemd unitConfigure the queue worker as a persistent system servicesudo nano /etc/systemd/system/laravel-queue.service 
Reload unit filesApply the new or updated systemd configurationsudo systemctl daemon-reload 
Start the worker and enable it at bootStart processing the queue and automatically launch the worker after a rebootsudo systemctl enable --now laravel-queue 
Check the statusVerify that the worker is running and does not exit with an errorsudo systemctl status laravel-queue 
Quickly check the serviceGet only the current process statussystemctl is-active laravel-queue 
View the logsIdentify startup or job execution errorssudo journalctl -u laravel-queue 
Follow the logs in real timeMonitor the worker while it processes jobssudo journalctl -u laravel-queue -f 
Gracefully restart Laravel workersFinish the current jobs and load the new code after systemd automatically restarts the processesphp artisan queue:restart 
Force restart the serviceImmediately stop the current worker and start a new processsudo systemctl restart laravel-queue 

Queued jobs no longer depend on an active SSH session and can be processed continuously, even after the VPS reboots.

Configuring the Laravel Scheduler

The queue worker handles background jobs that have been added to the queue. However, an application may also have tasks that need to run on a schedule rather than in response to an event.

For example, Laravel can clear temporary data every night, send a report once an hour, or check an external system every minute. The scheduler is used for these scenarios.

Why You Need a Scheduler

The Laravel scheduler lets you define schedules within the application itself instead of configuring dozens of separate cron jobs on the server.

For example, an application may have several tasks:

  • Every 5 minutes → check for new data
  • Every hour → generate statistics
  • Every night → delete old temporary files

All these rules are stored in Laravel alongside the project code.

This is convenient for several reasons:

  • The schedule is version-controlled along with the application;
  • It is easier to transfer between servers;
  • Developers do not need to manually maintain dozens of crontab entries;
  • All recurring task logic remains within Laravel.

In other words, the scheduler acts as the application’s internal schedule manager.

However, for it to work at all, an external mechanism must still run Laravel regularly and ask: is there a task that needs to be executed now?

This is where cron comes in.

How the Laravel Scheduler Differs from cron

cron is the Linux system scheduler.

It knows nothing about Laravel, jobs, Artisan, or the application structure. It simply runs the specified command at the specified time.

The Laravel Scheduler operates at a higher level.

cron runs a single Laravel command:

In other words, instead of creating a dozen separate cron entries, you can keep just one:

every minute → ask Laravel what needs to run

Laravel then determines which tasks actually need to run during that particular minute.

For example:

This way, cron is responsible only for invoking Laravel at regular intervals, while all scheduling logic remains within the application.

This leads to an important principle: a single application typically does not need a separate cron entry for every task.

Why Only One Scheduler Invocation Is Needed

The Laravel scheduler is designed so that the server regularly runs a single command, while the application itself determines what to do next.

Manually duplicating individual tasks in cron defeats the main purpose of the scheduler.

In addition, multiple identical scheduler invocations may cause the same task to run several times simultaneously.

For example:

This is harmless for some operations, but not for others.

Sending duplicate emails, importing the same data more than once, or cleaning the same table concurrently can cause problems.

Therefore, a single system-level scheduler trigger is usually sufficient for one application instance.

If the application is deployed across multiple servers, the situation becomes more complex: safeguards against concurrent execution must be considered separately. Laravel provides mechanisms for this, such as running a task on only one server.

In our setup, the application runs on a single VPS, so we use one cron entry.

Practical setup: cron or a systemd timer

For Laravel, cron is the simplest and most conventional option.

Open the crontab for the user that should run the application commands. In our setup, Laravel already runs as www-data, so we can edit that user’s crontab:

 sudo crontab -u www-data -e

Add the following line:

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

This entry changes to the Laravel directory and runs the scheduler every minute.

Laravel itself does not run every task each time. It checks the task schedules and runs only those that are due at the current time.

Before relying on cron, it is a good idea to test the scheduler manually:

 cd /var/www/laravel-app
php artisan schedule:list

This command displays the registered tasks and their schedules.

You can then run a manual check:

 php artisan schedule:run

Laravel will run only the tasks that are due at that time.

To verify that the cron entry was saved, run:

 sudo crontab -u www-data -l

To check the system cron service itself, run:

 systemctl is-active cron

For more details, run:

 sudo systemctl status cron

If a systemd timer is more suitable for the project, you can use one instead of cron. However, a separate timer typically offers few advantages for a single Laravel application, so we will use the standard cron-based approach here.

Key Scheduler Commands

TaskPurposeCommand
Open the Laravel user’s crontabAdd a system cron entry to run the schedulersudo crontab -u www-data -e 
Run the scheduler every minuteAllow Laravel to check its internal schedule regularly* * * * * cd /var/www/laravel-app && /usr/bin/php artisan schedule:run >> /dev/null 2>&1 
View Laravel scheduled tasksCheck which scheduled tasks are registeredphp artisan schedule:list 
Run the scheduler manuallyVerify that tasks run without waiting for cronphp artisan schedule:run 
View the crontabMake sure the entry has been savedsudo crontab -u www-data -l 
Check cronMake sure the system scheduler is runningsystemctl is-active cron 
View cron statusIdentify system scheduler startup errorssudo systemctl status cron 

Laravel can now also perform two different types of background work: the queue worker processes queued jobs, while the scheduler runs tasks on a schedule.

Enabling HTTPS

The application is already accessible through Nginx over HTTP, so we can now add TLS and switch external traffic to HTTPS.

The process is straightforward: first, make sure the domain points to the VPS and that Nginx serves the site correctly over HTTP. Only then should you issue the certificate. This makes it much easier to distinguish DNS and web server issues from TLS-specific errors.

We will use Let’s Encrypt, a widely used certificate authority that provides free, short-lived certificates with automated issuance and renewal.

Why HTTP Must Work First

Before issuing a certificate, make sure the site is accessible over standard HTTP.

This is important for several reasons.

First, the domain must resolve to the IP address of our VPS. If the DNS records point elsewhere, the certificate authority will be unable to verify that the server actually controls the domain.

Second, Nginx must already accept requests for the correct server_name. Otherwise, Certbot may be unable to update the configuration automatically, or domain validation may fail.

Third, during standard HTTP validation, Let’s Encrypt temporarily accesses a special path within .well-known. This is why we previously excluded this directory when blocking access to other hidden files in the Nginx configuration.

In simplified terms, the validation process works as follows:

If this path is publicly accessible and DNS already points to the correct server, the certificate can be issued.

Therefore, before proceeding to the next step, check:

http://app.example.com

If the application loads over HTTP without errors, you can proceed with Certbot.

How Certbot Works with Nginx

Certbot is a client that automates obtaining and installing TLS certificates.

When used with Nginx, it can not only request a certificate from Let’s Encrypt but also automatically update the site configuration.

The process works roughly as follows:

Certbot typically adds the following to the server block:

  • a listener on port 443;
  • paths to the certificate files;
  • the path to the private key;
  • the recommended TLS settings;
  • an HTTP → HTTPS redirect, if necessary.

Nginx then begins accepting encrypted connections.

TLS is terminated at Nginx.

The result is:

Laravel does not handle TLS decryption itself. It receives the request from Nginx as a regular internal request.

This also means that the SSL certificate is primarily associated with the domain and the Nginx configuration, rather than with Laravel itself.

Once the certificate has been issued, one question remains: what happens in a few months, when it is about to expire?

What Happens During Automatic Certificate Renewal

Let’s Encrypt certificates have a limited validity period, so they must be renewed regularly.

Renewing them manually each time is inconvenient, so Certbot usually sets up an automatic renewal mechanism.

The system periodically runs a check:

Certbot does not issue a new certificate every time it runs. It first checks whether the current certificate is close enough to its expiration date to require renewal.

On Ubuntu, the renewal mechanism is typically triggered automatically by a systemd timer or the corresponding Certbot package service.

After a successful renewal, the website configuration usually does not require manual changes: the certificate paths remain the same, while the certificate files themselves are updated.

Rather than waiting for the certificate to expire, Certbot allows you to test the renewal process in advance.

You can now proceed with issuing the certificate.

Hands-on: Issuing a Certificate

Install Certbot and the Nginx plugin:

 sudo apt install -y certbot python3-certbot-nginx

Before issuing the certificate, check the Nginx configuration again:

 sudo nginx -t

Make sure the server is responding over HTTP:

 curl -I http://app.example.com

Now request a certificate:

 sudo certbot --nginx -d app.example.com

On its first run, Certbot may prompt you to enter an email address and accept the terms of service.

Once the domain has been successfully validated, the certificate will be issued and the Nginx configuration will be updated.

Check HTTPS:

 curl -I https://app.example.com

You can also open the website in a browser and verify that the connection is shown as secure.

View the issued certificates:

 sudo certbot certificates

Now test automatic renewal with a dry run:

 sudo certbot renew --dry-run

This command does not wait for the certificate to expire or issue a new one. Instead, it uses a staging environment with higher rate limits to verify that Certbot can complete the entire renewal process.

If your Certbot installation uses a systemd timer, you can check its status as follows:

 systemctl status certbot.timer

To view the schedule:

 systemctl list-timers | grep certbot

Next, as usual, comes a table.

Essential HTTPS Commands

TaskPurposeCommand
Install CertbotInstall the Let’s Encrypt client and Nginx integrationsudo apt install -y certbot python3-certbot-nginx 
Check NginxEnsure that the configuration is valid before making changessudo nginx -t 
Test HTTPEnsure that the domain is already accessible before issuing the certificatecurl -I http://app.example.com 
Issue a certificateObtain a TLS certificate and configure Nginx to use itsudo certbot --nginx -d app.example.com 
Test HTTPSEnsure that the site responds over a secure connectioncurl -I https://app.example.com 
View certificatesCheck domains and expiration datessudo certbot certificates 
Test certificate renewalEnsure that automatic renewal will complete successfullysudo certbot renew --dry-run 
Check the timerEnsure that automatic renewal checks are enabledsystemctl status certbot.timer 

External access to the application is now secured with TLS: users connect to Nginx over HTTPS, and requests then follow the previously configured Nginx → PHP-FPM → Laravel chain.

The remaining step is to verify how well the entire setup handles a routine VPS reboot. Next, we will ensure that Nginx, PHP-FPM, PostgreSQL, the queue worker, and the application itself all restart automatically after a reboot.

Verify Operation After Reboot

What Should Start Automatically

After the server reboots, all persistent components in our setup should start automatically:

  • Nginx;
  • PHP-FPM;
  • PostgreSQL;
  • Laravel queue worker;
  • cron job for the scheduler;
  • Certbot certificate renewal mechanism.

Laravel itself does not run as a separate daemon. The web application starts working when Nginx receives a request and passes it to PHP-FPM.

After a reboot, the chain should therefore be restored roughly as follows:

If each of these components is configured to start automatically, the administrator does not need to start the application manually after server maintenance or a reboot.

Now let’s look at which parts of this chain most commonly fail.

What May Not Survive a Reboot

Problems usually arise not with system services, but with processes that were started manually.

For example, the following will not survive a reboot:

  • a queue worker started directly from an SSH session without systemd;
  • a command started manually and left running in a terminal;
  • a temporary development server;
  • a process that someone forgot to add to systemd;
  • a user script with no automatic startup configuration.

Another possible scenario is that a service starts automatically but immediately exits with an error.

For example, a queue worker may start before a required dependency, PHP-FPM may be unable to read its configuration, or PostgreSQL may fail to start because of a data issue.

Therefore, checking the enabled status alone is not enough. After a reboot, verify that the services are not only configured to start automatically but are also actually in the active state.

Cron requires separate attention. If the scheduler entry is stored in the www-data user’s crontab, the entry itself will survive a reboot, but the system cron service must also be running.

After checking the individual services, you can verify the entire application chain.

How to Verify the Entire Stack After a Restart

It is best to work from the bottom up, starting with the system components and ending with the external HTTPS endpoint.

First, check PostgreSQL and PHP-FPM. If they are not running, Laravel will not be able to handle requests properly.

Next, check Nginx and the queue worker.

Then check the scheduler, and only after that test the application externally.

The sequence is as follows:

This makes it easier to determine exactly where the failure occurred.

For example, if Nginx is running but PHP-FPM is not, this may appear externally as a 502 Bad Gateway error.

If the entire web application is running but the queue worker has not started, the website will load normally, but background jobs will begin to accumulate.

If cron is not running, scheduled tasks will simply stop running, even though HTTP and the queues remain operational.

Therefore, after a reboot, it is important to verify the entire stack rather than checking only a single page in the browser.

Final Verification Commands

First, reboot the VPS:

 sudo reboot

After reconnecting via SSH, check the core services:

 systemctl is-active \
  nginx \
  php8.5-fpm \
  postgresql \
  laravel-queue \
  cron

Under normal conditions, each service should return:

 active

If any component failed to start, immediately check its detailed status:

 sudo systemctl status <service-name>

Then check its journal:

 sudo journalctl -u <service-name> -n 50 --no-pager

Now check Laravel:

 cd /var/www/laravel-app
php artisan about

Verify that the application can still connect to PostgreSQL:

 php artisan migrate:status

Check the scheduler:

 php artisan schedule:list

Also verify that the cron entry is still present:

 sudo crontab -u www-data -l

Now check the external endpoint:

 curl -I https://app.example.com

If HTTPS is responding correctly, you can check the certificate separately:

 sudo certbot certificates

Then check the queue worker:

 sudo systemctl status laravel-queue

To begin, let us summarize in a single table the persistent system services required for the setup to recover fully after a reboot.

Checking System Services

ComponentWhy Check ItCommand
NginxAccepts HTTP/HTTPS requestssystemctl is-active nginx 
PHP-FPMExecutes Laravel codesystemctl is-active php8.5-fpm 
PostgreSQLStores application datasystemctl is-active postgresql 
Queue workerProcesses background jobssystemctl is-active laravel-queue 
CronRuns the Laravel schedulersystemctl is-active cron 

If all these services are in the active state, the basic server layer is operational. However, this is not enough: you must also verify that Laravel itself starts, can access the database, the scheduler has saved the schedule, and the application is accessible externally over HTTPS.

Checking the Application

CheckPurposeCommand
Laravel is runningVerify that PHP and the configuration are workingphp artisan about
PostgreSQL is accessibleVerify the application’s database connectionphp artisan migrate:status
The scheduler detects the tasksCheck Laravel’s internal schedulephp artisan schedule:list
The cron entry is savedVerify that the scheduler will runsudo crontab -u www-data -l
HTTPS is workingVerify the entire external request chaincurl -I https://app.example.com
The certificate existsCheck the TLS statussudo certbot certificates

If all these checks pass after a reboot, the basic setup is fully autonomous and does not depend on processes being started manually.

Next, we can move on to common issues. We will start with 403 Forbidden, an error most often caused by an incorrect document root or improper permissions on Laravel files.

Fixing the 403 Forbidden Error

Why Nginx May Return 403

A 403 Forbidden error means that the request reached Nginx, but access to the resource is denied.

For Laravel, the typical causes are:

  • The root directive points to the project root instead of /public;
  • The www-data user does not have access to one of the directories in the path;
  • The directory exists, but Nginx cannot read its contents;
  • Index.php is inaccessible because of its permissions;
  • The configuration contains an overly restrictive deny rule;
  • Nginx resolves the request to a directory instead of a file, but no index file is configured for that directory.

For example, if you specify:

 root /var/www/laravel-app;

instead of:

 root /var/www/laravel-app/public;

Nginx begins treating the project’s internal structure as the web root. This is insecure and can also lead to unexpected directory access behavior.

Another common scenario is incorrect permissions on one of the parent directories.

Even if public/index.php has the correct permissions, Nginx must be able to traverse the entire path:

If the user or group lacks permission to traverse any directory in the path, the web server may return 403.

Therefore, when troubleshooting, it is important to check not only the file itself but also the entire path to it.

How to Distinguish a Laravel Error from an Nginx Error

It is useful to understand this distinction from the outset because a 403 error can occur at different layers.

If the 403 Forbidden page is generated by Nginx itself, the request most likely never reached Laravel.

In this case, check:

However, if Laravel has already started and denied the action itself—for example, through middleware, a policy, or authorization—the error will be handled at the application layer.

The request flow then looks like this:

One of the easiest ways to distinguish between the layers is to check the logs.

If the issue is with Nginx, an entry will usually appear in:

 /var/log/nginx/laravel-app.error.log

For example, the log may contain messages such as permission denied or directory index … is forbidden.

If the request reached Laravel, check:

 /var/www/laravel-app/storage/logs/

The appearance of the page can also sometimes indicate the source of the error: a standard, minimal Nginx error page and a styled application error page usually look different. However, you should not rely on appearance alone—the logs are more reliable.

Once it becomes clear that file access is the root cause, it can be tempting to fix everything with a single chmod 777 command.

Why chmod 777 Is a Bad Idea

chmod 777 grants read, write, and execute permissions to all users on the system.

Specifically, 777 represents the following permissions:

 rwxrwxrwx

which means that the file owner, the file’s group, and all other users (!) have permission to read, edit, and execute (!) files.

These permissions are almost always too broad for a production environment.

If they are applied to the entire Laravel project, any local process that gains access to these files will be able to modify:

  • PHP code;
  • .env;
  • configuration;
  • dependencies;
  • public files.

Granting write access to directories containing executable PHP code is particularly dangerous. If the web application has a vulnerability, an attacker could potentially not only write a temporary file to storage, but also modify the application code itself and then execute it directly on the server.

On the other hand, overly restrictive permissions can also prevent Laravel from working properly.

PHP-FPM must be able to write data to:

 storage/
bootstrap/cache/

The goal is not to make the entire project writable, but to grant each component exactly the permissions it needs.

As a general rule:

This is why, when troubleshooting a 403 error, you should first identify the actual cause and only then adjust permissions where necessary.

Commands for diagnosing and resolving 403 errors

First, check the document root in the Nginx configuration:

 sudo nginx -T | grep -A5 "server_name app.example.com"

Look for the following line:

 root /var/www/laravel-app/public;

Check whether index.php exists:

 ls -l /var/www/laravel-app/public/index.php

Now check the permissions along the entire path:

 namei -l /var/www/laravel-app/public/index.php

This command is especially useful because it shows the owner and permissions of every directory along the path to the file.

Check the public directory itself:

 ls -ld /var/www/laravel-app/public

Then check the project root:

 ls -ld /var/www/laravel-app

If the issue involves Laravel’s working directories, check:

 ls -ld \
  /var/www/laravel-app/storage \
  /var/www/laravel-app/bootstrap/cache

If necessary, restore www-data as the group owner:

 sudo chgrp -R www-data \
  /var/www/laravel-app/storage \
  /var/www/laravel-app/bootstrap/cache

Then set the required permissions:

 sudo chmod -R ug+rwX \
  /var/www/laravel-app/storage \
  /var/www/laravel-app/bootstrap/cache

After making the changes, be sure to validate the Nginx configuration:

 sudo nginx -t

Then reload it:

 sudo systemctl reload nginx

If the 403 error persists, review the latest error log entries:

 sudo tail -n 50 /var/log/nginx/laravel-app.error.log

If necessary, monitor the log while repeating the request:

 sudo tail -f /var/log/nginx/laravel-app.error.log

Then check the site again:

 curl -I https://app.example.com

For convenience, the main scenarios can be summarized in a single table:

SymptomLikely causeWhat to check
Nginx immediately returns a 403 error Incorrect document rootroot must point to /var/www/laravel-app/public 
The log contains Permission denied Nginx or PHP-FPM cannot traverse the directoriesnamei -l /var/www/laravel-app/public/index.php 
index.php exists, but the site does not loadInsufficient permissions on public or its parent directoryls -ld /var/www/laravel-app /var/www/laravel-app/public 
Laravel cannot write logs or cache dataNo write access to the runtime directoriesPermissions on storage and bootstrap/cache 
The error log contains directory index … is forbidden Nginx is resolving the request to a directory instead of the entry fileroot, index, try_files 
The 403 error occurs only on specific routesThe request has already reached LaravelMiddleware, authorization, policies, and Laravel logs
The error appeared after an Nginx configuration changeIncorrect location or deny directive sudo nginx -T and the error log
The problem disappears only after running chmod 777 The actual permissions issue is being masked by overly broad accessThe owner, group, and permissions of the relevant directories

If checking the root directive, permissions along the entire path, and the error log reveals the cause, the 403 error can usually be resolved without granting broader access to the entire project.

Troubleshooting a 502 Bad Gateway Error

With Nginx, a 403 Forbidden error is usually caused by an incorrect path, insufficient permissions, or access control rules. A 502 Bad Gateway error indicates a different issue: Nginx received the request but was unable to get a valid response from PHP-FPM.

In other words, the request reached the web server, but the next component in the request chain failed.

What a 502 Error Means in an Nginx + PHP-FPM Setup

In this setup, Nginx does not execute PHP code itself.

It forwards requests to PHP-FPM via FastCGI:

If communication between Nginx and PHP-FPM fails, the request never reaches Laravel.

For example, Nginx may be configured to use:

 /run/php/php8.5-fpm.sock

while PHP-FPM has created a different socket or is not running at all.

In that case, the request chain breaks roughly at this point:

That is why, when troubleshooting a 502 error, you should first check the Nginx → PHP-FPM connection, not the Laravel routes or PostgreSQL.

The next step is to determine what typically breaks this connection.

Common Causes

The most obvious cause is that PHP-FPM is not running.

In this case, Nginx attempts to forward the request, but there is no process to receive it via FastCGI.

Another common cause is an incorrect socket path.

For example, after a PHP upgrade, the server may now use:

 /run/php/php8.5-fpm.sock

while the Nginx configuration still contains the old path:

 /run/php/php8.4-fpm.sock

Nginx itself may still start without any issues. The error will occur only when a user opens a PHP page.

Another possibility is that the socket exists, but Nginx cannot access it due to insufficient permissions.

The issue may not involve a Unix socket at all. If PHP-FPM is configured to use TCP, such as 127.0.0.1:9000, while Nginx continues to look for a socket file, the result will be the same.

A 502 error may also occur if PHP-FPM starts but immediately crashes due to a configuration error or insufficient resources.

The causes can therefore be divided into several groups:

  • PHP-FPM is not running;
  • Nginx is using the wrong upstream;
  • the socket does not exist;
  • the socket is inaccessible due to insufficient permissions;
  • PHP-FPM is listening on TCP while Nginx is configured to use a socket, or vice versa;
  • the PHP-FPM configuration is invalid;
  • the process terminates due to insufficient memory or another system issue.

Next, we will look at how to quickly distinguish between these scenarios.

How to Quickly Identify the Problem

As a general rule, start with the simplest checks and work your way up to the more complex ones.

First, check whether PHP-FPM is running at all.

If the service is inactive or failed, troubleshoot PHP-FPM rather than Nginx.

If PHP-FPM is active, the next question is where it listens for requests.

Our configuration expects:

 /run/php/php8.5-fpm.sock

Make sure this file actually exists.

Next, compare this path with the fastcgi_pass value in the Nginx configuration.

This gives us a simple check:

Then check the logs.

When there is a socket issue, Nginx often logs messages such as:

 connect() to unix:/run/php/php8.5-fpm.sock failed

If PHP-FPM cannot start, the cause will usually be visible in its journal.

For example:

 sudo journalctl -u php8.5-fpm

This makes it possible to quickly determine exactly where the request chain is failing.

If Nginx and PHP-FPM are both running, the socket paths match, and the 502 error persists, check the system load, the PHP-FPM pool, and the system logs.

Commands for Diagnosing and Fixing 502 Errors

First, check the PHP-FPM status:

 systemctl is-active php8.5-fpm

If the service is not active, view its detailed status:

 sudo systemctl status php8.5-fpm

Then check the latest messages:

 sudo journalctl -u php8.5-fpm -n 50 --no-pager

Now check the socket:

 ls -l /run/php/php8.5-fpm.sock

If the file exists, verify that PHP-FPM is actually listening on that socket:

 grep -R "^listen =" /etc/php/8.5/fpm/pool.d/

Next, check the fastcgi_pass directive in Nginx:

 sudo nginx -T | grep fastcgi_pass

For our setup, the expected value is:

 fastcgi_pass unix:/run/php/php8.5-fpm.sock;

If the paths differ, configure Nginx and PHP-FPM to use the same value.

After making changes, first test the Nginx configuration:

 sudo nginx -t

Then restart PHP-FPM:

 sudo systemctl restart php8.5-fpm

Then reload Nginx:

 sudo systemctl reload nginx

Now check the application again:

 curl -I https://app.example.com

If the error persists, check the Nginx error log:

 sudo tail -n 50 /var/log/nginx/laravel-app.error.log

If necessary, monitor it in real time:

 sudo tail -f /var/log/nginx/laravel-app.error.log

If the PHP-FPM journal contains configuration errors, you can test the configuration separately:

 sudo php-fpm8.5 -t

If you suspect that the process is being terminated due to insufficient memory, check the system journal:

 sudo journalctl -k | grep -i -E 'oom|killed process'

Next, as usual, we have a table:

CauseHow to CheckSolution
PHP-FPM is stoppedsystemctl is-active php8.5-fpm Start or restart php8.5-fpm 
PHP-FPM fails to startsudo systemctl status php8.5-fpm and journalctl Fix the configuration error or system issue
Incorrect socket configured in Nginxsudo nginx -T | grep fastcgi_pass Specify the actual PHP-FPM socket
Socket does not existls -l /run/php/php8.5-fpm.sock Check whether PHP-FPM is running and which listen value is configured
PHP-FPM is listening on a different addressgrep -R “^listen =” /etc/php/8.5/fpm/pool.d/ Align the listen and fastcgi_pass values 
No access to the socketls -l /run/php/php8.5-fpm.sock and the Nginx error logCorrect the owner, group, or PHP-FPM pool settings
PHP-FPM configuration errorsudo php-fpm8.5 -t Fix the configuration file and restart the service
PHP-FPM was terminated by the systemjournalctl -k Check RAM, OOM events, and VPS load

If PHP-FPM is active, the socket exists, and fastcgi_pass points to that exact socket, most common causes of a 502 Bad Gateway error have already been ruled out.

After addressing 403 and 502 errors, one more category of less obvious issues remains: Laravel loads but cannot write logs, cache data, session data, or temporary files. Next, we will examine permissions for storage, bootstrap/cache, and the project itself.

Fixing Access Permission Issues

Which Laravel Directories Must Be Writable

Most of a Laravel project is read-only during normal operation.

The application code, vendor, config, routes, and other source files should not be continually modified by the PHP-FPM process.

Write access is generally required in two locations:

 storage/
bootstrap/cache/

The storage directory is used for logs, cache data, sessions, temporary files, and compiled views.

The bootstrap/cache directory stores optimized configuration files and other data generated by Laravel.

The general model is as follows:

If PHP-FPM does not have write access to storage, Laravel may be unable to create laravel.log or save a file-based session, or it may encounter a Permission denied error.

If bootstrap/cache is inaccessible, issues may arise when running config:cache, route:cache, or other Artisan commands.

Therefore, write permissions should be granted specifically to these directories.

Why permissions for the deploy and www-data users can conflict

On a production server, the code is often updated by one user while the application runs as another.

For example:

The deploy user may create new files and directories while updating the application.

PHP-FPM, however, runs as www-data and must be able to read the code and write runtime data.

If these users are not linked through a shared group and appropriate permissions, conflicts will eventually arise.

For example, deploy may create a new directory inside storage that only the deploy user can access. Everything appears normal immediately after deployment, but the first request handled by PHP-FPM results in a Permission denied error.

The reverse can also happen: www-data creates a cache file that the user performing the deployment cannot subsequently modify.

In other words, the issue is often not Laravel itself, but the fact that two Linux users take turns working with the same directories.

One simple way to avoid this is to use a shared group, such as www-data, and grant it write access only to runtime directories.

This keeps the code under the control of the deploy user while giving PHP-FPM the permissions it needs only where they are actually required.

Why the Owner and Group Matter More Than chmod

chmod determines which actions are permitted for the owner, group, and other users.

But on its own, it does not answer the question of who exactly is considered the owner and who belongs to the required group.

For example, the following permissions:

 drwxrwxr-x

mean:

  • The owner can read, write, and traverse the directory;
  • The group can also read, write, and traverse the directory;
  • Other users can only read and traverse the directory.

However, if the directory’s group is not www-data, PHP-FPM will not benefit from the group’s rwx permissions.

Therefore, always check the following first:

and only then change the permissions with chmod.

For example, a reasonable setup for runtime directories might look like this:

 owner: deploy
group: www-data
permissions: ug+rwX

This allows the deploy user to manage the files, while PHP-FPM gets the required write access through the group.

This is much better than indiscriminately setting permissions to 777.

If the permissions continue to be reset after every deployment, consider configuring group inheritance separately for the directories. For a basic setup, however, it is sufficient to first set the correct owner, group, and permissions for storage and bootstrap/cache.

Commands for Checking and Fixing Permissions

First, check the owner and group of the project directory:

 ls -ld /var/www/laravel-app

Check the runtime directories:

 ls -ld \
  /var/www/laravel-app/storage \
  /var/www/laravel-app/bootstrap/cache

To view permissions in more detail for the entire path:

 namei -l /var/www/laravel-app/storage

Now assign the directories that Laravel needs to write to to the www-data group:

 sudo chgrp -R www-data \
  /var/www/laravel-app/storage \
  /var/www/laravel-app/bootstrap/cache

Grant read and write permissions to the owner and group:

 sudo chmod -R ug+rwX \
  /var/www/laravel-app/storage \
  /var/www/laravel-app/bootstrap/cache

If deployment is performed by a dedicated user named deploy, add that user to the www-data group:

 sudo usermod -aG www-data deploy

The user must then log in to the shell again for the new group membership to take effect.

Check the user’s groups:

 groups deploy

Use more restrictive permissions for the .env file:

 chmod 600 /var/www/laravel-app/.env

Verify the permissions:

 ls -l /var/www/laravel-app/.env

You can now test write access as www-data.

For example:

 sudo -u www-data touch /var/www/laravel-app/storage/test-write

If the command succeeds, delete the test file:

 sudo rm /var/www/laravel-app/storage/test-write

It is also useful to check Laravel:

 cd /var/www/laravel-app
php artisan about

Then verify that the application can work properly with logs and the cache.

Essential Permission-Checking Commands

TaskPurposeCommand
Check the project ownerVerify who controls the codels -ld /var/www/laravel-app 
Check the runtime directoriesView the owner, group, and permissionsls -ld storage bootstrap/cache 
Check the entire pathIdentify the directory where access is blockednamei -l /var/www/laravel-app/storage 
Assign the www-data group Grant PHP-FPM access to the runtime directoriessudo chgrp -R www-data storage bootstrap/cache 
Grant write access to the owner and groupAllow Laravel to create cache, log, and temporary filessudo chmod -R ug+rwX storage bootstrap/cache 
Add the deployment user to the groupPrevent conflicts between deployment and PHP-FPMsudo usermod -aG www-data deploy 
Check the user’s groupsVerify that the new group membership has taken effectgroups deploy 
Restrict access to .env Protect credentials and configurationchmod 600 .env 
Test write access as PHP-FPMVerify that www-data can actually writesudo -u www-data touch storage/test-write 

If www-data can write to storage and bootstrap/cache while the rest of the project is not made writable unnecessarily, the permissions are configured securely enough for our setup.

Now that we have covered the main common errors, we can bring all the components together in one final check and ensure that Laravel, PostgreSQL, Nginx, HTTPS, the queue worker, and the scheduler work together as a single system.

Verifying the Entire Deployment

System Services

ComponentWhy Check ItCommand
NginxHandles HTTP/HTTPS requestssystemctl is-active nginx 
PHP-FPMExecutes Laravel PHP codesystemctl is-active php8.5-fpm 
PostgreSQLStores application datasystemctl is-active postgresql 
Queue workerProcesses background jobssystemctl is-active laravel-queue 
CronRuns the Laravel schedulersystemctl is-active cron 

If all services are in the active state, you can proceed to Laravel itself and check the application’s internal components.

Laravel and Background Tasks

CheckPurposeCommand
Laravel is runningCheck PHP, .env, and the basic configurationphp artisan about 
PostgreSQL is reachableEnsure that the application can connect to the databasephp artisan migrate:status 
The scheduler detects the tasksCheck the internal schedulephp artisan schedule:list 
The cron entry existsEnsure that the scheduler will actually runsudo crontab -u www-data -l 
The queue worker is runningCheck background job processingsudo systemctl status laravel-queue 
Runtime directories are accessibleRule out write-access issuesls -ld storage bootstrap/cache 

If Laravel is running, can connect to PostgreSQL, and the background services are active, all that remains is to test the external user path—from DNS and HTTPS to the application’s response.

External Access

CheckPurposeCommand
HTTP endpoint respondsVerify that Nginx is accessiblecurl -I http://app.example.com 
HTTPS endpoint respondsVerify the entire external request chaincurl -I https://app.example.com 
Certificate existsVerify TLS and the certificate’s expiration datesudo certbot certificates 
Nginx configuration is validRule out errors introduced by recent changessudo nginx -t 
The error log contains no critical errorsCheck for Nginx and FastCGI issuessudo tail -n 50 /var/log/nginx/laravel-app.error.log 

If all three levels pass their checks, the final architecture looks like this:

This means that the application not only opens in a browser but is also fully operational: the database is accessible, background jobs are processed, scheduled tasks run, HTTPS works, and the core services start automatically after a reboot.

How to Prepare a Laravel VPS for Production

Technically, the application is already deployed and running. However, before moving to production, you should review the settings that affect not only the application itself, but also security, recovery from failures, and ongoing maintenance.

No architectural changes are required at this stage. You need to verify that the production environment does not retain unnecessary debugging information, secrets are protected, backups are available, and major errors can be quickly located in the logs.

This section can be considered supplementary rather than mandatory, but we still recommend reading it.

Disable Debug Mode

The production configuration should include:

 APP_ENV=production
APP_DEBUG=false

APP_DEBUG=true is useful during development, but on a public server it can expose too much internal information, including stack traces, file paths, class structures, SQL fragments, and other technical details.

After changing the .env file, remember to update the configuration cache. If the cache has already been created, Laravel may continue using the old value.

Therefore, after changing the environment settings, update the configuration:

 php artisan config:clear
php artisan config:cache

You can then check the current environment by running php artisan about.

With debug mode addressed, the next obvious item to secure is the .env file itself, as it contains most of the application’s sensitive settings.

Restrict Permissions on .env

The .env file must not be accessible through a browser, a public Git repository, or to arbitrary local users on the server.

It may contain:

  • PostgreSQL password;
  • APP_KEY;
  • SMTP credentials;
  • API tokens;
  • Third-party service keys;
  • Queue and cache settings.

The document root is already set to public, so Nginx should not serve .env directly.

However, this is not enough: permissions on the file itself should also be restricted.

For example:

 chmod 600 /var/www/laravel-app/.env

This allows only the file owner to read and modify it.

It is also important to consider which user runs the deployment commands and PHP-FPM. Overly restrictive permissions can cause problems if file ownership is not taken into account, so .env must be protected while remaining accessible to the processes that actually need it.

After securing the secrets, you should also verify which user runs the project’s administrative commands.

Do Not Run Composer or Artisan as root Unless Necessary

Composer and Artisan commands often create new files:

  • Dependencies in vendor;
  • Cache;
  • Compiled views;
  • Temporary files;
  • Logs;
  • Results of migrations and maintenance operations.

If these commands are routinely run with sudo, some files may end up owned by root.

The regular deployment user or www-data will then no longer be able to modify them.

A typical sequence of events looks like this:

Therefore, it is best to run Composer and Artisan as the regular user who owns the project code.

sudo should be used only when system-level privileges are actually required—for example, to manage systemd, Nginx, packages, or file ownership.

This keeps the permissions model predictable and eliminates the need to repeatedly fix ownership after every deployment.

The next level of production readiness is data recovery.

Configure Backups for PostgreSQL and .env

If the server is lost, the Git repository alone will not be enough to restore the application.

The repository usually contains the code, but not:

  • PostgreSQL data;
  • The production .env file;
  • User uploads;
  • Local secrets;
  • Certain runtime files.

At a minimum, you should therefore back up the PostgreSQL database and the production configuration.

For PostgreSQL, you can create regular dumps using pg_dump and then transfer them to separate storage.

For example, the setup might look like this:

Keeping your only backup on the same VPS is not particularly useful: if the server is lost or compromised, both the application and the backup will be lost.

The .env file must also be backed up separately and securely. Do not commit it to a regular Git repository simply to make recovery easier.

It is better to store an encrypted backup or use a dedicated secrets manager.

Most importantly, periodically verify not only that the backup files exist, but also that the database can actually be restored from them. An unusable backup is usually discovered at the worst possible time.

In addition to backups, a production environment requires proper observability. At a minimum, you should know where to look for an error.

Monitor the Nginx, PHP-FPM, and Laravel logs

The issue can occur at different layers, so laravel.log alone is not enough.

Nginx reports errors from the external HTTP layer:

 /var/log/nginx/laravel-app.error.log

Laravel stores application errors in:

 /var/www/laravel-app/storage/logs/

Depending on the configuration, PHP-FPM writes messages to the systemd journal and its own log files.

Queues use a separate systemd journal:

 sudo journalctl -u laravel-queue

It is useful to organize troubleshooting by layer from the outset:

On a small VPS, this is enough to avoid searching every directory whenever an error occurs.

However, queues have another peculiarity: the worker may continue running while individual jobs fail.

Monitor the queue and failed jobs

An active systemd service status only indicates that the worker process is running.

It does not guarantee that all jobs will run successfully.

For example:

Therefore, in addition to monitoring the worker itself, you should also monitor failed jobs.

Laravel lets you view them using Artisan:

 php artisan queue:failed

Once the issue has been resolved, individual jobs can be retried.

In production, you should also monitor:

  • The number of failed jobs;
  • Job execution time;
  • Recurring exceptions;
  • Queue growth;
  • Excessively frequent retries.

If the queue is used extensively, it makes sense to set up dedicated monitoring. For a small application, however, regularly checking for failed jobs is a good baseline.

The final step is to consolidate all the production tasks into a single checklist.

What to Check Before Production

Before deployment, it is best to review each group of settings separately. First, check the application configuration and access permissions.

Configuration and Access

CheckExpected Configuration
Laravel environmentAPP_ENV=production 
DebugAPP_DEBUG=false 
.env Not stored in a public Git repository; access is restricted
Document rootPoints to /var/www/laravel-app/public 
PostgreSQLA separate database and user are used
PostgreSQL portNot exposed externally unless required
Runtime directoriesThe application has write access to storage and bootstrap/cache
All other codeNot writable unless required

Once the basic configuration and permissions are set correctly, check the persistent processes that the application depends on after a reboot.

Services, Queue, and HTTPS

CheckExpected Result
PHP-FPMRunning and using the correct socket
Queue WorkerRunning under systemd and enabled to start automatically
SchedulerThe cron entry exists, and the tasks are listed by schedule:list 
HTTPSThe certificate is valid
TLS Renewalcertbot renew --dry-run completes without errors
RebootThe application comes back online after a reboot without being started manually

If the services remain operational after a reboot and external access works reliably, all that remains is to check what will be needed in the event of a failure or during recovery.

Backups and Diagnostics

CheckRequirement
PostgreSQL BackupConfigured and stored separately from the VPS
.env Backup Stored securely off-server
Nginx LogsAvailable for diagnostics
Laravel LogsWritten without permission errors
Failed JobsChecked using queue:failed 
RecoveryThe backup is checked periodically to ensure it is usable

If all three groups are covered, the VPS is ready not only to run Laravel but also for normal operation.

Conclusion

A stack consisting of Nginx, PHP-FPM, PostgreSQL, a queue worker, and a scheduler is well suited to small and medium-sized Laravel projects that need a straightforward, manageable production environment without unnecessary infrastructure complexity. This could be a corporate portal, an internal service, a small online store, an API, an early-stage SaaS project, or a private application with moderate traffic.

The main advantage of this setup is its predictability. All core components run on a single VPS, making them easy to inspect, restart, and troubleshoot. At the same time, the architecture remains flexible enough to accommodate growth: PostgreSQL can be moved to a separate server, Redis can be added, the number of queue workers can be increased, or load balancing can be introduced without completely redesigning the application.

If the project is still small, I would not overcomplicate the infrastructure prematurely. It is far more useful to first ensure that this basic setup runs reliably, configure backups, logging, queues, and post-reboot checks, and then scale based on actual load.

Thank you for reading!

FAQ

Can Laravel be deployed without Nginx?

Technically, Laravel can be run using PHP’s built-in server or the php artisan serve command, but these are primarily development tools. In production, a full-featured web server and PHP-FPM are typically used.

In our setup, Nginx accepts incoming traffic, serves static files, and forwards PHP requests to PHP-FPM via FastCGI.

Is PostgreSQL required?

No. Laravel supports several database management systems, and the specific choice depends on the project. This guide uses PostgreSQL as the primary database, so we installed pdo_pgsql and created a dedicated user and database.

If the application already uses MySQL, MariaDB, or another supported database management system, there is no need to switch solely for this setup.

Why does Laravel load while the queue does not process any jobs?

The website and the queue worker run as separate processes.

An HTTP request may be handled successfully by Nginx and PHP-FPM even if the worker has stopped. Therefore, check laravel-queue, the systemd logs, and failed jobs separately.

Laravel also notes that queue:work is a long-running process and must be managed by a process manager to run continuously.

Do queue workers need to be restarted after every deployment?

Yes, if the code used by the worker has changed.

queue:work keeps the loaded application state in memory and does not automatically detect code changes. Laravel recommends restarting workers during deployment; queue:restart allows workers to restart after completing their current job.

What should you do if APP_DEBUG=false but you still need detailed error information?

Look for detailed information in the application and service logs, not in the browser.

For Laravel, check storage/logs; for Nginx, check the error log; and for PHP-FPM and queue workers, check the systemd journal.

Avoid enabling debugging in production solely for diagnostic purposes: the official Laravel documentation explicitly warns that APP_DEBUG must be disabled in production.

Why does the scheduler run every minute if the task only needs to run once a day?

Because cron does not execute every Laravel task every minute. It only runs schedule:run, after which Laravel checks its internal schedule and determines which tasks are due to run.

The official Laravel documentation specifies a single cron entry for the scheduler.

Can Supervisor be used instead of systemd for queues?

Yes.

The Laravel documentation often cites Supervisor as an example of a process manager for queue:work. This guide uses systemd because it is already available in Ubuntu and allows the worker to be managed in the same way as other services.

The principle is the same in both cases: the worker must start automatically, restart if it fails, and run independently of an active SSH session.

Why does Laravel continue to use the old value after the .env file is changed?

A common cause is the configuration cache.

If the configuration has been cached, Laravel may continue to use the previously compiled values. After changing the .env file, refresh the configuration cache by running config:clear followed by config:cache.

Do you need to expose PostgreSQL to the internet?

If Laravel and PostgreSQL are running on the same VPS, usually not.

The application can connect to the database at 127.0.0.1:5432 while PostgreSQL remains inaccessible from outside the VPS. Even so, you should use a dedicated application role rather than the administrative postgres role. PostgreSQL manages access through roles and their privileges.

Is pg_dump sufficient for production backups?

For a small project, it can be a convenient part of a backup strategy, but PostgreSQL itself notes that other backup mechanisms should also be considered for regular production backups in nontrivial scenarios.

pg_dump exports a single database; global objects such as roles are not included—for those, use pg_dumpall. 

What happens if Certbot cannot renew the certificate?

The existing certificate will not disappear immediately—it will continue to work until it expires. However, if the issue is not resolved before then, HTTPS connections will begin returning a certificate error.

It is therefore a good idea to test certificate renewal in advance by running certbot renew –dry-run. Certbot officially supports the renew command, the Nginx plugin, and dry-run renewal testing. You should also consider monitoring the certificate status and configuring alerts for renewal failures.

Sources

  1. Laravel Documentation — Deployment
  2. Laravel Documentation — Queues
  3. Laravel Documentation — Task Scheduling
  4. Nginx Documentation — ngx_http_fastcgi_module
  5. Certbot Documentation

Subscribe to our newsletter and receive articles and news

    Check out our other materials