In chapter 1 of this guide, I took you through the initial steps of setting up and securing a VPS on DigitalOcean using Ubuntu 24.04. In this chapter I will guide you through the process of setting up Nginx, PHP-FPM, and MySQL — which on Linux is more commonly known as a LEMP stack — that will form the foundations of a working web application and server.

Before moving on with this tutorial, you will need to open a new SSH connection to the server, if you haven’t already:

ssh abe@pluto.turnipjuice.media
Welcome to Ubuntu 24.04.3 LTS (GNU/Linux 6.8.0-83-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Wed Sep 17 13:56:57 UTC 2025

  System load:  0.0               Users logged in:       0
  Usage of /:   4.5% of 47.39GB   IPv4 address for eth0: 178.62.70.190
  Memory usage: 16%               IPv4 address for eth0: 10.50.0.5
  Swap usage:   0%                IPv6 address for eth0: 2604:a880:5:1::d04:0
  Processes:    99

Expanded Security Maintenance for Applications is not enabled.

0 updates can be applied immediately.

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


Last login: Wed Sep 17 13:57:30 2025 from 190.140.118.55
abe@pluto:~$

Install Nginx

Nginx has become the most popular web server software used on Linux servers, so it makes sense to use it rather than Apache. Although the official Ubuntu package repository includes Nginx packages, they’re often very outdated. We want to take advantage of the latest performance improvements, security patches, and HTTP/3 support, so we are going to install Nginx directly from the official NGINX mainline repository.

First, we need to install the prerequisites necessary for adding a new secure repository:

sudo apt update
sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring

Next, download and save the official NGINX signing key so our system can verify the packages:

curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null

Now, let’s add the official NGINX mainline repository to our apt sources:

echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/mainline/ubuntu `lsb_release -cs` nginx" | sudo tee /etc/apt/sources.list.d/nginx.list

Since Ubuntu also maintains an Nginx package in its default repositories, we need to tell apt to always prefer the packages coming from nginx.org. To do this, we need to create a preference file to pin the repository:

echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" | sudo tee /etc/apt/preferences.d/99nginx

Finally, update your package lists to pull in the new repository and install Nginx:

sudo apt update
sudo apt install nginx

Once installed, we can check the version to confirm we are running the latest mainline release:

abe@pluto:~$ nginx -v
nginx version: nginx/1.31.1

Now you can try visiting the domain name pointing to your server’s IP address in your browser and you should see an Nginx welcome page. Make sure to type in http:// as browsers default to https:// now and that won’t work as we have yet to set up SSL.

Welcome to Nginx

Now that Nginx has successfully been installed it’s time to perform some basic configuration. Out-of-the-box Nginx is pretty well optimized, but there are a few basic adjustments to make. However, before opening the configuration file, you need to determine your server’s open file limit.

Run the following to get your server’s open file limit and take note, as we’ll use it in a minute:

ulimit -n

Next, open the Nginx configuration file, which can be found at /etc/nginx/nginx.conf:

sudo nano /etc/nginx/nginx.conf
user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

I’m not going to list every configuration directive but I am going to briefly mention those that you should change. If you would find it easier to see the whole thing at once, feel free to download the complete Nginx config kit now.

Start by setting the user to the username that you’re currently logged in with. This will make managing file permissions much easier in the future, but this is only acceptable security-wise when running a server where only a single user has access.

The events block contains the worker_connections directive, which should be set to your server’s open file limit. This tells Nginx how many simultaneous connections can be opened by each worker_process. Therefore, if you have two CPU cores and an open file limit of 1024, your server can handle 2048 connections per second. However, the number of connections doesn’t directly equate to the number of users that can be handled by the server, as the majority of web pages and browsers open at least two connections per request.

Next, add the following line below the worker_connections directive:

multi_accept        on;

This will inform each worker_process to accept all new connections at the same time, as opposed to accepting one new connection at a time.

Moving down the file you will see the http block. Let’s begin by uncommenting tcp_nopush:

tcp_nopush     on;

The tcp_nopush directive enables TCP_CORK on Linux. This option tells the kernel to accumulate data until a full TCP packet can be sent, preventing packet fragmentation and improving packet throughput.

The next directive to change is keepalive_timeout. The keepalive_timeout directive determines how many seconds a connection to the client should be kept open before it’s closed by Nginx. This directive should be lowered, as you don’t want idle connections sitting there for up to 65 seconds if they can be utilized by new clients. I have set mine to 15.

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    tcp_nopush     on;

    keepalive_timeout  15;

Next, add the following line below the keepalive_timeout directive:

server_tokens      off;

For security reasons, you should always add the server_tokens directive and set it to off. This will disable emitting the Nginx version number in error messages and response headers.

Underneath the server_tokens directive, add the following line to set the maximum upload size you require in the WordPress Media Library.

client_max_body_size 64m;

I chose a value of 64m but you can increase it if you run into issues uploading large files.

Further down the http block, you will see a section dedicated to gzip compression. By default, the gzip directive is disabled, but you should uncomment it to enable it. You should also add the following lines below the gzip directive:

gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
    application/atom+xml
    application/javascript
    application/json
    application/ld+json
    application/manifest+json
    application/rss+xml
    application/vnd.geo+json
    application/vnd.ms-fontobject
    application/x-font-ttf
    application/x-web-app-manifest+json
    application/xhtml+xml
    application/xml
    font/opentype
    image/bmp
    image/svg+xml
    image/x-icon
    text/cache-manifest
    text/css
    text/plain
    text/vcard
    text/vnd.rim.location.xloc
    text/vtt
    text/x-component
    text/x-cross-domain-policy;
  # text/html is always compressed when gzip is enabled.

The gzip_vary directive tells proxies to cache both, the compressed gzip file and the regular uncompressed version of it. It also avoids the issue where a non-gzip capable client would display gibberish if their proxy gave them the compressed gzip version of it. Next, we set the gzip_proxied directive to any, which will ensure all proxied request responses are gzipped. Moving on, we add the gzip_comp_level directive and set it to a value of 5. This controls the compression level of a response and can have a value in the range of 1 – 9. Be careful not to set this value too high, as it can have a negative impact on CPU usage. Then, we add the gzip_min_length directive and set it to a value of 256. This avoids compressing anything that is already small to begin with and would be very unlikely to shrink further, if at all. Finally, we add the gzip_types directive. This will ensure that JavaScript, CSS, and other file types are gzipped in addition to the HTML file type which is always compressed by the gzip module.

That’s the basic Nginx configuration dealt with. Hit CTRL + X followed by Y to save the changes.

You must restart Nginx for the changes to take effect. Before doing so, ensure that the configuration files contain no errors by issuing the following command:

sudo nginx -t

If everything looks OK, go ahead and restart Nginx:

sudo systemctl restart nginx.service

If it’s not already running, you can start Nginx with:

sudo systemctl enable --now nginx.service
abe@pluto:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
abe@pluto:~$ sudo systemctl enable --now nginx.service
Synchronizing state of nginx.service with SysV service script with /usr/lib/systemd/systemd-sysv-install.
Executing: /usr/lib/systemd/systemd-sysv-install enable nginx
abe@pluto:~$

Install PHP 8.4

Just as with Nginx, the official Ubuntu package repository does contain PHP packages. However, they are not the most up-to-date. Again, I use one maintained by Ondřej Surý for installing PHP. Add the repository and update the package lists as you did for Nginx:

sudo add-apt-repository ppa:ondrej/php -y
sudo apt update

Then install PHP 8.4, as well as all the PHP packages you will require:

sudo apt install php8.4-fpm php8.4-common php8.4-mysql \
php8.4-xml php8.4-intl php8.4-curl php8.4-gd \
php8.4-imagick php8.4-cli php8.4-dev php8.4-imap \
php8.4-mbstring php8.4-opcache php8.4-redis \
php8.4-soap php8.4-zip -y

You’ll notice php-fpm in the list of packages being installed. FastCGI Process Manager (FPM) is an alternative PHP FastCGI implementation with some additional features that plays really well with Nginx. It’s the recommended process manager to use when installing PHP with Nginx.

After the installation has completed, test PHP and confirm that it has been installed correctly:

php-fpm8.4 -v
abe@pluto:~$ php-fpm8.4 -v
PHP 8.4.12 (fpm-fcgi) (built: Aug 29 2025 06:48:12) (NTS)
Copyright (c) The PHP Group
Built by Debian
Zend Engine v4.4.12, Copyright (c) Zend Technologies
    with Zend OPcache v8.4.12, Copyright (c), by Zend Technologies

Configure PHP 8.4 and PHP-FPM

Once Nginx and PHP are installed you need to configure the user and group that the service will run under. This setup does not provide security isolation between sites by configuring PHP pools, so we will run a single PHP pool under your user account. If security isolation between sites is required we do not recommend that you use this approach and instead use SpinupWP to provision your servers.

Open the default pool configuration file:

sudo nano /etc/php/8.4/fpm/pool.d/www.conf

Change the following lines, replacing www-data with your username:

user = abe
group = abe
listen.owner = abe
listen.group = abe

Hit CTRL + X and Y to save the configuration.

Next, you should adjust your php.ini file to increase the WordPress maximum upload size. Both this and the client_max_body_size directive within Nginx must be changed for the new maximum upload limit to take effect. Open your php.ini file:

sudo nano /etc/php/8.4/fpm/php.ini

Change the following lines to match the value you assigned to the client_max_body_size directive when configuring Nginx:

upload_max_filesize = 64M
post_max_size = 64M

While we’re editing the php.ini file, let’s also enable the OPcache file override setting. When this setting is enabled, OPcache will serve the cached version of PHP files without checking if the file has been modified on the file system, resulting in improved PHP performance.

Hit CTRL + W and type file_override to locate the line we need to update. Now uncomment it (remove the semicolon) and change the value from zero to one:

opcache.enable_file_override = 1

Hit CTRL + X and Y to save the configuration. Before restarting PHP, check that the configuration file syntax is correct:

sudo php-fpm8.4 -t
abe@pluto:~$ sudo php-fpm8.4 -t
[21-Sep-2025 03:58:04] NOTICE: configuration file /etc/php/8.4/fpm/php-fpm.conf test is successful

If the configuration test was successful, restart PHP using the following command:

sudo systemctl restart php8.4-fpm.service

Now that Nginx and PHP have been installed, you can confirm that they are both running under the correct user by issuing the htop command:

htop

If you hit SHIFT + M, the output will be arranged by memory usage which should bring the php-fpm processes into view. If you scroll to the bottom, you’ll also find a couple of nginx processes.

Both processes will have one instance running under the root user. This is the main process that spawns each worker. The remainder should be running under the username you specified.

Screenshot of a terminal window showing htop to monitor server resources

If not, go back and check the configuration, and ensure that you have restarted both the Nginx and PHP-FPM services.

Test Nginx and PHP

To check that Nginx and PHP are working together properly, enable PHP in the default Nginx site configuration and create a PHP info file to view in your browser. You are welcome to skip this step, but it’s often handy to check that PHP files can be correctly processed by the Nginx web server.

First, you need to uncomment a section in the default Nginx site configuration which was created when you installed Nginx:

sudo nano /etc/nginx/conf.d/default.conf

Find the section which controls the PHP scripts.

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

As we’re using php-fpm, we can change that section to look like this:

    # pass the PHP scripts to FastCGI server listening on unix:/run/php/php8.4-fpm.sock;

    location ~ \.php$ {
      root          /usr/share/nginx/html;
      fastcgi_pass  unix:/run/php/php8.4-fpm.sock;
      fastcgi_index index.php;
      fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
      include       fastcgi_params;
    }

Save the file by using CTRL + X followed by Y. Then, as before, test to make sure the configuration file was edited correctly.

sudo nginx -t

If everything looks okay, go ahead and restart Nginx:

sudo systemctl restart nginx.service

Next, create an info.php file in the default web root, which is /usr/share/nginx/html.

cd /usr/share/nginx/html
sudo nano info.php

Add the following PHP code to that info.php file, and save it by using the same CTRL + X, Y combination.

<?php phpinfo();

Lastly, because you set the user directive in your nginx.conf file to the user you’re currently logged in with, give that user permissions on the info.php file.

sudo chown abe info.php

Now, if you visit the info.php file in your browser, using the domain name you set up in chapter 1, you should see the PHP info screen, which means Nginx can process PHP files correctly.

PHP info screen.

Once you’ve tested this, you can go ahead and delete the info.php file.

sudo rm /usr/share/nginx/html/info.php

Configure a Catch-All Server Block

Currently, when you visit the server’s domain name in a web browser you will see the Nginx welcome page. However, it would be better if the server returned an empty response for domain names that have not been configured in Nginx.

Begin by removing the default site configuration:

sudo rm /etc/nginx/conf.d/default.conf

Now you need to add a catch-all block to the Nginx configuration. Edit the nginx.conf file:

sudo nano /etc/nginx/nginx.conf

Towards the bottom of the file you’ll find a line that reads:

include /etc/nginx/conf.d/*.conf;

Underneath that, add the following block:

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    server_name _;

    return 444;
}

Hit CTRL + X followed by Y to save the changes and then test the Nginx configuration:

sudo nginx -t

If everything looks good, restart Nginx:

sudo systemctl restart nginx.service

Now when you visit your domain name you should receive an error:

Screenshot of browser error.

Here’s my final nginx.conf file, after applying all of the above changes.

user  abe;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /run/nginx.pid;


events {
    worker_connections  1024;
    multi_accept        on;
}


http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    tcp_nopush     on;

    keepalive_timeout  15;
    server_tokens      off;
    client_max_body_size 64m;

    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_types
        application/atom+xml
        application/javascript
        application/json
        application/ld+json
        application/manifest+json
        application/rss+xml
        application/vnd.geo+json
        application/vnd.ms-fontobject
        application/x-font-ttf
        application/x-web-app-manifest+json
        application/xhtml+xml
        application/xml
        font/opentype
        image/bmp
        image/svg+xml
        image/x-icon
        text/cache-manifest
        text/css
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;
      # text/html is always compressed when gzip is enabled.

    include /etc/nginx/conf.d/*.conf;

    server {
      listen 80 default_server;
      listen [::]:80 default_server;

      server_name _;

      return 444;
    }
}

Download the complete set of Nginx config files

Install WP-CLI

If you have never used WP-CLI before, it’s a command-line tool for managing WordPress installations, and greatly simplifies the process of downloading and installing WordPress (plus many other tasks).

Navigate to your home directory:

cd ~/

Using cURL, download WP-CLI:

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar

You can then check that it works by issuing:

php wp-cli.phar --info

The command should output information about your current PHP version and a few other details.

To access the command-line tool by simply typing wp, you need to move it into your server’s PATH and ensure that it has execute permissions:

chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp

You can now access the WP-CLI tool by typing wp.

NAME

  wp

DESCRIPTION

  Manage WordPress through the command-line.

SYNOPSIS

  wp 

SUBCOMMANDS

  cache             Adds, removes, fetches, and flushes the WP Object Cache object.
  cap               Adds, removes, and lists capabilities of a user role.
  cli               Reviews current WP-CLI info, checks for updates, or views defined aliases.
  comment           Creates, updates, deletes, and moderates comments.
  config            Generates and reads the wp-config.php file.
  core              Downloads, installs, updates, and manages a WordPress installation.

Install MySQL 8.4

The final package to install is MySQL. By default, Ubuntu provides MySQL packages through its own repository, but these are often one or more major versions behind the official MySQL releases. To ensure access to the latest stable and long-term supported (LTS) versions, we’ll configure Ubuntu to use MySQL’s official APT repository instead. This approach guarantees timely security patches, newer features, and better alignment with upstream support.

To access MySQL’s repository, we’ll first need to download MySQL’s apt configuration release package:

wget https://dev.mysql.com/get/mysql-apt-config_0.8.36-1_all.deb

Once downloaded, we can go ahead and install it with:

sudo dpkg -i mysql-apt-config_0.8.36-1_all.deb

Next, select Ok to complete the installation:

Screenshot of mysql-apt-config settings.

Lastly, proceed to update the repository with the following command:

sudo apt update

We’re now ready to install MySQL on the server. Simply run the following command:

sudo apt install mysql-server -y
At the time of writing, MySQL’s repository does not yet support ARM-based CPUs. If you’re running Ubuntu on ARM hardware, then running sudo apt install mysql-server -y will automatically select packages from the Ubuntu repository instead.

You’ll be prompted to set a password for MySQL’s root user:

Screenshot of MySQL's root user password prompt.

Enter a password and select Ok.

Finally, to complete the setup process, we’ll go ahead and run MySQL’s secure installation script:

sudo mysql_secure_installation

Follow the instructions and answer the questions. You’ll enter the password that you just set. Here are my answers:

abe@pluto:~$ sudo mysql_secure_installation

Securing the MySQL server deployment.

Enter password for user root:

VALIDATE PASSWORD COMPONENT can be used to test passwords
and improve security. It checks the strength of password
and allows the users to set only those passwords which are
secure enough. Would you like to setup VALIDATE PASSWORD component?

Press y|Y for Yes, any other key for No: y

There are three levels of password validation policy:

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

Please enter 0 = LOW, 1 = MEDIUM and 2 = STRONG: 2
Using existing password for root.

Estimated strength of the password: 100
Change the password for root ? ((Press y|Y for Yes, any other key for No) : n

 ... skipping.
By default, a MySQL installation has an anonymous user,
allowing anyone to log into MySQL without having to have
a user account created for them. This is intended only for
testing, and to make the installation go a bit smoother.
You should remove them before moving into a production
environment.

Remove anonymous users? (Press y|Y for Yes, any other key for No) : y
Success.


Normally, root should only be allowed to connect from
'localhost'. This ensures that someone cannot guess at
the root password from the network.

Disallow root login remotely? (Press y|Y for Yes, any other key for No) : y
Success.

By default, MySQL comes with a database named 'test' that
anyone can access. This is also intended only for testing,
and should be removed before moving into a production
environment.


Remove test database and access to it? (Press y|Y for Yes, any other key for No) : y
 - Dropping test database...
Success.

 - Removing privileges on test database...
Success.

Reloading the privilege tables will ensure that all changes
made so far will take effect immediately.

Reload privilege tables now? (Press y|Y for Yes, any other key for No) : y
Success.

All done!
abe@pluto:~$

That’s all for this chapter. In the next chapter I will guide you through the process of setting up your first WordPress site and how to manage multiple WordPress installs.