Ls
Application Stacks Azure

LEMP Stack on Ubuntu 24.04 on Azure User Guide

| Product: LEMP Stack on Ubuntu 24.04 LTS on Azure

Overview

The LEMP stack is the nginx counterpart of the classic LAMP stack: Linux, nginx (pronounced "engine x", the E), MariaDB and PHP. Where Apache traditionally embeds a PHP interpreter in the web server process, nginx keeps the two separate: it serves static files itself and hands PHP requests to a dedicated PHP FPM process pool over a unix socket. That event driven model is why LEMP handles large numbers of simultaneous connections on modest hardware, which makes it a popular choice for content management systems, custom PHP sites and API backends.

This cloudimg image wires the whole stack together and hardens it, on a fully patched Ubuntu 24.04 LTS base. nginx 1.24 serves port 80, PHP 8.3 runs as an FPM pool, and MariaDB 10.11 listens only on the loopback interface. Every password is generated uniquely on the first boot of each VM, with no default login anywhere in the image, and an application database is created and ready for your code before you first sign in. phpMyAdmin 5.2.3 is included for browser based database administration. The database and the web document root each live on their own dedicated Azure data disk. Backed by 24/7 cloudimg support.

What is included:

  • nginx 1.24 serving port 80, with PHP dispatched to PHP FPM over a unix socket
  • PHP 8.3 FPM with the extension set modern applications expect, including mysqli, pdo_mysql, mbstring, gd, intl, bcmath, zip, curl, xml and opcache
  • MariaDB 10.11, bound to 127.0.0.1 only and never exposed to the network
  • A ready to use application database and a least privilege database user, created on first boot
  • A live /status.php stack status page that proves nginx, PHP FPM and MariaDB are all working together
  • phpMyAdmin 5.2.3 at /phpmyadmin for browser based database administration
  • Per VM passwords for the database root account, the phpMyAdmin control user and the application user, recorded in a root only file
  • Two dedicated Azure data disks: one for the database at /var/lib/mysql, one for the document root at /var/www
  • nginx.service, php8.3-fpm.service and mariadb.service as enabled systemd units
  • An unauthenticated /healthz endpoint for Azure Load Balancer health probes
  • 24/7 cloudimg support

Prerequisites

An active Azure subscription, an SSH key pair, and a VNet plus subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a sensible starting point; size up for busier sites or larger databases. NSG inbound: allow 22/tcp from your management network and 80/tcp (plus 443/tcp once you add TLS) for the web tier. The MariaDB server is never exposed: it listens on 127.0.0.1 only, so port 3306 stays off the network and needs no NSG rule.

Step 1 - Deploy from the Azure Marketplace

Sign in to the Azure Portal, choose Create a resource, search the Marketplace for LEMP Stack by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTP (80). Review the two dedicated data disks on the Disks tab, then Review + create then Create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group <your-rg> \
  --name lemp-stack \
  --image <marketplace-image-urn> \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

Then open port 80 to the web tier:

az vm open-port --resource-group <your-rg> --name lemp-stack --port 80

Step 3 - Confirm the stack is running

SSH in as azureuser and confirm nginx, PHP FPM and MariaDB are all active, along with the one shot first boot service that generated this VM's credentials.

systemctl is-active nginx php8.3-fpm mariadb lemp-stack-firstboot
active
active
active
active

Check what is listening. nginx is on port 80 on all interfaces; MariaDB is on 127.0.0.1:3306 only, so it is unreachable from the network.

ss -tlnp | grep -E ':80 |:3306 ' | sed 's/  */ /g'
LISTEN 0 80 127.0.0.1:3306 0.0.0.0:* users:(("mariadbd",pid=14319,fd=21))
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=14679,fd=5),("nginx",pid=14678,fd=5),("nginx",pid=14351,fd=5))
LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=14679,fd=6),("nginx",pid=14678,fd=6),("nginx",pid=14351,fd=6))

The nginx, php8.3-fpm, mariadb and lemp-stack-firstboot services all active, with nginx listening on port 80 and MariaDB bound to loopback 127.0.0.1:3306

Step 4 - Retrieve this VM's credentials

Every VM generates its own database passwords on first boot and writes them to a root only file. Nothing is shared between VMs and no default login ships in the image. Read the file with sudo:

sudo cat /root/lemp-stack-credentials.txt

The per VM credentials file listing the phpMyAdmin sign in user and password, and the ready to use application database name, user and password, with the passwords masked

The file records three things you will use:

  • LEMP_MARIADB_ROOT_PASSWORD - sign in to phpMyAdmin as user root with this.
  • LEMP_APP_DB_NAME, LEMP_APP_DB_USER and LEMP_APP_DB_PASSWORD - a database that already exists, with a user whose privileges are limited to it. Point your application at these.
  • LEMP_URL, LEMP_STATUS_URL and LEMP_PHPMYADMIN_URL - the stack's entry points, built from the address the VM detected for itself at first boot. If your VM's public IP is assigned after that point, or you are reading this from outside the VNet, substitute the VM's public IP from the Azure Portal in these URLs; the paths are unchanged.

On the box itself you can always reach the database directly over the local unix socket, with no password, because root@localhost is pinned to socket authentication:

sudo mariadb -e 'SELECT VERSION();'

Step 5 - Verify the stack end to end

The health endpoint is served by nginx alone, with no PHP involved, and is what an Azure Load Balancer probe should target:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/healthz
200

The /status.php page is the real proof. Serving it exercises all three tiers in a single request: nginx accepts it, PHP FPM executes it, and it opens a connection to MariaDB and reads a row back. It prints the LEMP_STATUS_OK marker only when every tier answered.

curl -s http://127.0.0.1/status.php | grep -oE 'LEMP_STATUS_OK|nginx/[0-9.]+|PHP [0-9.]+ via [a-z-]+|MariaDB [0-9][^<]*' | head -5
LEMP_STATUS_OK
nginx/1.24.0
PHP 8.3.6 via fpm-fcgi
MariaDB 10.11.14-MariaDB-0ubuntu0.24.04.1

Open the same page in a browser at http://<public-ip>/status.php and you get the readable version, including the live query result read back from the database:

The LEMP Stack status page reporting LEMP_STATUS_OK, with nginx 1.24.0 as the web server, PHP 8.3.6 running via fpm-fcgi, MariaDB 10.11.14 as the database, and the live query result read back from the cloudimg_status table

Browsing to http://<public-ip>/ gives you the welcome page, which links to the status page, the PHP runtime report and phpMyAdmin:

The cloudimg LEMP Stack welcome page served by nginx, linking to the status page, the PHP runtime report and phpMyAdmin

Step 6 - Administer the database from phpMyAdmin

Browse to http://<public-ip>/phpmyadmin/ and sign in as user root with the LEMP_MARIADB_ROOT_PASSWORD from Step 4. phpMyAdmin uses cookie authentication, protected by a blowfish secret that is also unique to this VM.

The phpMyAdmin sign in page, reached at the /phpmyadmin path on the LEMP stack

Once signed in, the left navigation lists every database on the server, including your application database and the phpMyAdmin control database. The SQL tab gives you a full query editor: type a statement, click Go, and the results render below with export, chart and bookmark options.

phpMyAdmin signed in and running a SELECT against the application database, with the returned row rendered in the results grid

Step 7 - Use the application database

The application database already exists and its user is limited to it, so you can start creating tables immediately. Read the password from the credentials file and connect over the loopback interface:

PW=$(sudo grep '^LEMP_APP_DB_PASSWORD=' /root/lemp-stack-credentials.txt | cut -d= -f2-); mariadb -h127.0.0.1 -ulempapp -p"$PW" lempapp -e 'SHOW TABLES; SELECT * FROM cloudimg_status;'
Tables_in_lempapp
cloudimg_status
id  status  checked_at
1   ok  2026-08-06 22:31:31

cloudimg_status is the single row the status page reads. It is safe to drop once your own schema is in place.

Step 8 - Deploy your own PHP application

Put your application in /var/www/html and nginx serves it immediately, with index.php taking precedence over the cloudimg welcome page. Your application's database credentials are already on the VM, in a file readable by the web user but outside the document root, so the password is never web reachable. Any PHP file can pull them in:

sudo tee /var/www/html/hello.php >/dev/null <<'PHP'
<?php
$db = require '/etc/cloudimg/lemp-db.php';
$c = new mysqli($db['host'], $db['user'], $db['pass'], $db['name'], (int) $db['port']);
printf("Connected to database '%s' as '%s' on MariaDB %s\n", $db['name'], $db['user'], $c->server_info);
PHP
curl -s http://127.0.0.1/hello.php
Connected to database 'lempapp' as 'lempapp' on MariaDB 10.11.14-MariaDB-0ubuntu0.24.04.1

To host a site under its own name, add a server block. The stack's own block is at /etc/nginx/sites-available/cloudimg-lemp; copy its location ~ \.php$ section, which is what routes PHP to the FPM socket:

server {
    listen 80;
    server_name www.example.com;
    root /var/www/example;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
}

Enable it and reload nginx:

sudo ln -s /etc/nginx/sites-available/example /etc/nginx/sites-enabled/example
sudo nginx -t && sudo systemctl reload nginx

Step 9 - Component versions

nginx -v 2>&1; php -v | head -1; mariadb --version
nginx version: nginx/1.24.0 (Ubuntu)
PHP 8.3.6 (cli) (built: Jul 16 2026 18:30:41) (NTS)
mariadb  Ver 15.1 Distrib 10.11.14-MariaDB, for debian-linux-gnu (x86_64) using  EditLine wrapper
grep -oE "VERSION = '[0-9.]+'" /usr/share/phpmyadmin/libraries/classes/Version.php | head -1
VERSION = '5.2.3'

The /phpinfo.php page reports the full PHP runtime and every loaded extension in the browser. Delete that file before putting a production site live.

The nginx, PHP and MariaDB versions, the phpMyAdmin version, the healthz endpoint returning 200, and the end to end status page round trip

Step 10 - Where your data lives

The two tiers sit on separate dedicated Azure data disks, so the database and your application files are both kept off the operating system disk and can be resized independently.

df -h /var/lib/mysql /var/www | sed 's/  */ /g'
Filesystem Size Used Avail Use% Mounted on
/dev/sda 20G 127M 19G 1% /var/lib/mysql
/dev/sdb 20G 52K 19G 1% /var/www
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/mysql; findmnt -no SOURCE,TARGET,FSTYPE /var/www
/dev/sda /var/lib/mysql ext4
/dev/sdb /var/www ext4

Both dedicated ext4 data disks, one holding the MariaDB datadir at /var/lib/mysql and one holding the document root at /var/www, with MariaDB bound to loopback

Both disks are captured into the image and re-provisioned with every VM, so this layout is what you get on every deployment.

Step 11 - Review the database accounts

Every account on the server is created by first boot with a password unique to this VM. root@localhost is deliberately pinned to unix_socket, which means it is usable only by root on the box itself and has no network usable password at all.

sudo mariadb -e "SELECT CONCAT(User,'@',Host) AS account, plugin FROM mysql.user ORDER BY User,Host;"
account plugin
lempapp@127.0.0.1   mysql_native_password
lempapp@localhost   mysql_native_password
mariadb.sys@localhost   mysql_native_password
mysql@localhost mysql_native_password
pma@127.0.0.1   mysql_native_password
pma@localhost   mysql_native_password
root@127.0.0.1  mysql_native_password
root@::1    mysql_native_password
root@localhost  unix_socket

There is no anonymous account, no test database and no packaged maintenance user: those are removed when the image is built and again on first boot. mariadb.sys and mysql are internal MariaDB accounts that are locked and reachable only over the local socket.

Enabling HTTPS with Let's Encrypt

Point a DNS A record at the VM's public IP, open 443/tcp in the NSG, then install certbot with the nginx plugin and request a certificate. Certbot edits the nginx server block in place and installs a renewal timer.

sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d www.example.com --agree-tos -m you@example.com --redirect

Verify the renewal timer afterwards with systemctl list-timers | grep certbot.

Security notes

  • The stack serves plain HTTP on port 80 out of the box. For anything beyond a trusted network, add TLS as above or put the VM behind Azure Application Gateway with your own certificate.
  • MariaDB listens on 127.0.0.1 only and is never reachable from the network. Administer it through phpMyAdmin, over SSH with sudo mariadb, or from your application over the loopback interface.
  • Every password is unique to this VM. Keep /root/lemp-stack-credentials.txt protected, and rotate the passwords if you share access to the machine.
  • Point your application at the lempapp user rather than root. Its privileges are limited to the application database, so a compromised application cannot reach the rest of the server.
  • The application credentials live at /etc/cloudimg/lemp-db.php, outside the document root and readable only by root and the web user, so they cannot be fetched over HTTP.
  • Delete /var/www/html/phpinfo.php before a production launch. It reports your PHP configuration in detail and is a build time convenience, not something to leave exposed.
  • Restrict inbound 80/tcp and 443/tcp in your NSG to the networks that need them, and keep 22/tcp limited to your management range.

Support

This image is maintained by cloudimg with 24/7 support. If you need help deploying or operating the LEMP stack on Azure, contact us at cloudimg.co.uk.