LAPP Stack on Ubuntu 24.04 on Azure User Guide
Overview
The LAPP stack is the PostgreSQL sibling of the classic LAMP stack: Linux, the Apache HTTP Server (the A), PostgreSQL (the P) and PHP. Apache embeds the PHP interpreter directly in the web server process with mod_php, so it executes PHP in place without a separate process pool, while PostgreSQL provides a robust, standards compliant relational database. It is a proven platform for content management systems, custom PHP sites and PostgreSQL backed applications.
This cloudimg image wires the whole stack together and hardens it, on a fully patched Ubuntu 24.04 LTS base. Apache 2.4 serves port 80 with PHP 8.3 running as mod_php, and PostgreSQL 16 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. Adminer 6.0.0 is included for browser based database administration. Backed by 24/7 cloudimg support.
What is included:
- Apache 2.4 serving port 80, with PHP executed in process by mod_php under the prefork MPM
- PHP 8.3 with the extension set modern applications expect, including pdo_pgsql, pgsql, mbstring, gd, intl, bcmath, zip, curl, xml and opcache
- PostgreSQL 16, bound to
localhostonly and never exposed to the network, with scram-sha-256 password authentication - A ready to use application database and a least privilege database role, created on first boot
- A live
/status.phpstack status page that proves Apache, PHP and PostgreSQL are all working together - Adminer 6.0.0 at
/adminerfor browser based PostgreSQL administration - Per VM passwords for the PostgreSQL superuser and the application role, recorded in a root only file
- A firstboot gated web tier: Apache only starts once first boot has minted this VM's credentials
apache2.serviceandpostgresql@16-main.serviceas enabled systemd units- An unauthenticated
/healthzendpoint 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. PostgreSQL is never exposed: it listens on localhost only, so port 5432 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 LAPP 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). Then Review + create then Create.
Step 2 - Deploy from the Azure CLI
az vm create \
--resource-group <your-rg> \
--name lapp-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 lapp-stack --port 80
Step 3 - Confirm the stack is running
SSH in as azureuser and confirm Apache and PostgreSQL are active, along with the one shot first boot service that generated this VM's credentials.
systemctl is-active apache2 postgresql@16-main lapp-stack-firstboot
active
active
active
Check what is listening. Apache is on port 80 on all interfaces; PostgreSQL is on 127.0.0.1:5432 only, so it is unreachable from the network.
ss -tlnp | grep -E ':80 |:5432 ' | sed 's/ */ /g'
LISTEN 0 244 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=15275,fd=6))
LISTEN 0 511 *:80 *:* users:(("apache2",pid=17118,fd=4),("apache2",pid=17117,fd=4))

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/lapp-stack-credentials.txt

The file records the things you will use:
LAPP_POSTGRES_PASSWORD- sign in to Adminer as userpostgreswith this for full administration.LAPP_APP_DB_NAME,LAPP_APP_DB_USERandLAPP_APP_DB_PASSWORD- a database that already exists, with a role whose privileges are limited to it. Point your application at these.LAPP_URL,LAPP_STATUS_URLandLAPP_ADMINER_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 the postgres operating system account uses peer authentication:
sudo -u postgres psql -c 'SELECT version();'
Step 5 - Verify the stack end to end
The health endpoint is served by Apache 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: Apache accepts it, mod_php executes it, and it opens a connection to PostgreSQL and reads a row back. It prints the LAPP_STATUS_OK marker only when every tier answered.
curl -s http://127.0.0.1/status.php | grep -oE 'LAPP_STATUS_OK|Apache|PHP [0-9.]+ via [a-z0-9]+|PostgreSQL [0-9.]+' | sort -u
Apache
LAPP_STATUS_OK
PHP 8.3.6 via apache2handler
PostgreSQL 16.14
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:

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

Step 6 - Administer the database from Adminer
Browse to http://<public-ip>/adminer/. Choose PostgreSQL as the system, enter 127.0.0.1 as the server, sign in as user postgres with the LAPP_POSTGRES_PASSWORD from Step 4, and optionally set the database to lappapp.

Once signed in, the left navigation lists every database on the server, including your application database. Selecting a table shows its rows, and the SQL command link gives you a full query editor. Here Adminer is showing the cloudimg_status row that the status page reads, straight from the application database:

Step 7 - Use the application database
The application database already exists and its role 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 '^LAPP_APP_DB_PASSWORD=' /root/lapp-stack-credentials.txt | cut -d= -f2-); PGPASSWORD="$PW" psql -w -h 127.0.0.1 -U lappapp -d lappapp -c '\dt' -c 'SELECT * FROM cloudimg_status;'
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+---------
public | cloudimg_status | table | lappapp
(1 row)
id | status | checked_at
----+--------+-------------------------------
1 | ok | 2026-08-08 04:42:33.947132+00
(1 row)
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 Apache 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/lapp-db.php';
$pdo = new PDO("pgsql:host={$db['host']};port={$db['port']};dbname={$db['name']}", $db['user'], $db['pass']);
$v = explode(' on ', (string) $pdo->query('SELECT version()')->fetchColumn())[0];
printf("Connected to database '%s' as '%s' on %s\n", $db['name'], $db['user'], $v);
PHP
curl -s http://127.0.0.1/hello.php
Connected to database 'lappapp' as 'lappapp' on PostgreSQL 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1)
To host a site under its own name, add a virtual host. The stack's own vhost is at /etc/apache2/sites-available/cloudimg-lapp.conf; copy it as a starting point:
<VirtualHost *:80>
ServerName www.example.com
DocumentRoot /var/www/example
DirectoryIndex index.php index.html
<Directory /var/www/example>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
</Directory>
</VirtualHost>
Enable it and reload Apache:
sudo a2ensite example && sudo apache2ctl configtest && sudo systemctl reload apache2
Step 9 - Component versions
apache2 -v | head -1; php -v | head -1; sudo -u postgres psql -tAc 'SELECT version()'
Server version: Apache/2.4.58 (Ubuntu)
PHP 8.3.6 (cli) (built: Jul 16 2026 18:30:41) (NTS)
PostgreSQL 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1) on x86_64-pc-linux-gnu, compiled by gcc 13.3.0, 64-bit
Adminer 6.0.0 is served at /adminer. 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.

Step 10 - Review the database roles and hardening
Every login role on the server is created by first boot with a password unique to this VM. The lappapp application role is not a superuser and is limited to the application database; the postgres superuser is reachable over TCP only with the per VM password.
sudo -u postgres psql -c "SELECT rolname, rolsuper, rolcanlogin FROM pg_roles WHERE rolcanlogin ORDER BY rolname"
rolname | rolsuper | rolcanlogin
----------+----------+-------------
lappapp | f | t
postgres | t | t
(2 rows)
PostgreSQL listens on localhost only, and its host based authentication uses scram-sha-256 on every TCP path with no trust method anywhere. Peer authentication on the local unix socket is what lets sudo -u postgres psql work on the box without a password.
sudo -u postgres psql -tAc 'SHOW listen_addresses'; grep -E '^(local|host)' /etc/postgresql/16/main/pg_hba.conf
localhost
local all postgres peer
local all all peer
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256

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 Apache plugin and request a certificate. Certbot edits the Apache virtual host in place and installs a renewal timer.
sudo apt-get update && sudo apt-get install -y certbot python3-certbot-apache
sudo certbot --apache -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.
- PostgreSQL listens on
localhostonly and is never reachable from the network. Administer it through Adminer, over SSH withsudo -u postgres psql, or from your application over the loopback interface. - Every password is unique to this VM. Keep
/root/lapp-stack-credentials.txtprotected, and rotate the passwords if you share access to the machine. - Point your application at the
lappapprole rather thanpostgres. 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/lapp-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.phpbefore a production launch. It reports your PHP configuration in detail and is a build time convenience, not something to leave exposed. - Restrict inbound
80/tcpand443/tcpin your NSG to the networks that need them, and keep22/tcplimited to your management range.
Support
This image is maintained by cloudimg with 24/7 support. If you need help deploying or operating the LAPP stack on Azure, contact us at cloudimg.co.uk.