Ls
Application Stacks Azure

LAPP Stack on Ubuntu 24.04 on Azure User Guide

| Product: LAPP Stack on Ubuntu 24.04 LTS on Azure

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 localhost only 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.php stack status page that proves Apache, PHP and PostgreSQL are all working together
  • Adminer 6.0.0 at /adminer for 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.service and postgresql@16-main.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. 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))

The apache2, postgresql and lapp-stack-firstboot services all active, with Apache listening on port 80 and PostgreSQL bound to loopback 127.0.0.1:5432

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 per VM credentials file listing the Adminer sign in user and PostgreSQL superuser password, and the ready to use application database name, role and password, with the passwords masked

The file records the things you will use:

  • LAPP_POSTGRES_PASSWORD - sign in to Adminer as user postgres with this for full administration.
  • LAPP_APP_DB_NAME, LAPP_APP_DB_USER and LAPP_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_URL and LAPP_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:

The LAPP Stack status page reporting LAPP_STATUS_OK, with Apache as the web server, PHP 8.3.6 running via apache2handler, PostgreSQL 16.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 Adminer:

The cloudimg LAPP Stack welcome page served by Apache, linking 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.

The Adminer sign in page reached at the /adminer path on the LAPP stack, with the PostgreSQL driver selected

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:

Adminer signed in to the lappapp database, showing the cloudimg_status table with its single seeded row rendered in the results grid

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.

The Apache, PHP and PostgreSQL versions, the Adminer version, the healthz endpoint returning 200, and the end to end status page round trip

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

PostgreSQL listening on localhost only, the loopback 5432 socket, the pg_hba.conf peer and scram-sha-256 rules with no trust method, and the suppressed Apache version banner

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 localhost only and is never reachable from the network. Administer it through Adminer, over SSH with sudo -u postgres psql, or from your application over the loopback interface.
  • Every password is unique to this VM. Keep /root/lapp-stack-credentials.txt protected, and rotate the passwords if you share access to the machine.
  • Point your application at the lappapp role rather than postgres. 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.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 LAPP stack on Azure, contact us at cloudimg.co.uk.