cloudimg PHP Web App Host on Ubuntu 24.04 on Azure User Guide
Overview
This image is a ready-to-run host for PHP web applications. It is not a bare interpreter: PHP-FPM and the CLI are installed and pinned, a working application built on the Laravel framework is already serving, a single-node PostgreSQL database is bundled on the same machine, and nginx sits in front as the single public entry point. A queue worker and the framework scheduler run as managed systemd units, so background and timed work function out of the box.
Everything you own lives on a dedicated data volume mounted at /srv/phpapp, separate from the operating system disk:
| Path | What it holds |
|---|---|
/srv/phpapp/app |
The PHP application |
/srv/phpapp/db |
The PostgreSQL cluster |
/srv/phpapp/notices |
Third-party licence notices |
/srv/phpapp/logs |
nginx and PHP-FPM logs |
Three properties are worth knowing before you start:
- Nothing is reachable until first boot has run. PHP-FPM, nginx, the queue worker and the scheduler all wait on a marker that first boot creates only after it has generated this machine's encryption key, database password and login. If first boot fails, the public port never opens.
- Only nginx is public. PHP-FPM listens on
127.0.0.1:9000and PostgreSQL on127.0.0.1:5432. Step 7 proves that, with a positive control. - Every secret is unique to your VM. The application encryption key, the database password and the admin login are generated on the machine, not baked into the image.
Prerequisites
- An Azure subscription with permission to create a VM.
- An SSH key pair.
- Inbound TCP 22 and 80 allowed in the network security group.
Step 1 - Deploy from the Azure Marketplace
In the Azure portal, search the Marketplace for cloudimg PHP Web App Host on Ubuntu 24.04, choose Create, and select a size. Standard_B2s (2 vCPU, 4 GiB) is the recommended minimum and is what this image is tuned for.
Leave the data disk that comes with the image in place — the application and the database both live on it.
Step 2 - Deploy from the Azure CLI
az vm create \
--resource-group <your-resource-group> \
--name <your-vm-name> \
--image cloudimg:php-web-app-host:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
az vm open-port \
--resource-group <your-resource-group> \
--name <your-vm-name> \
--port 80
Step 3 - Retrieve the per-VM secrets
Every VM generates its own secrets on first boot. SSH in and read them:
sudo cat /root/laravel-credentials.txt
You will see the application URL, the username admin, the admin password, and the PostgreSQL database name, user and password — all unique to this machine. The file is 0600 root:root, so only root can read it.
Confirm the permissions, and that the application encryption key was generated for this VM rather than shipped in the image:
sudo stat -c '%a %U:%G %n' /root/laravel-credentials.txt
sudo grep -E '^(APP_ENV|APP_DEBUG)=' /srv/phpapp/app/.env
sudo grep -c '^APP_KEY=base64:' /srv/phpapp/app/.env
APP_ENV is production and APP_DEBUG is false — debug mode would leak environment variables and stack traces, so it is off. The last command prints 1, confirming this machine has its own APP_KEY. That key encrypts every session and cookie the application issues; because it is generated per VM, sessions from one deployment are meaningless to another.

Step 4 - Verify the deployment
Check the units are running and that the listeners are where they should be:
systemctl is-active php8.3-fpm nginx postgresql phpapp-queue phpapp-scheduler.timer
ss -ltn | grep -E ':(80|9000|5432)'
nginx listens on 0.0.0.0:80. PHP-FPM listens on 127.0.0.1:9000 and PostgreSQL on 127.0.0.1:5432 — loopback only. That is the whole point of the architecture, and Step 7 proves it.
The unauthenticated health probe should answer immediately:
curl -s http://127.0.0.1/health

nginx serves everything under /srv/phpapp/app/public directly, without going through PHP.
Confirm that path works — it is what will carry your stylesheets, scripts and images:
PW=$(sudo awk -F= '/^PHPAPP_ADMIN_PASSWORD=/{print substr($0,index($0,"=")+1)}' /root/laravel-credentials.txt)
curl -s -o /dev/null -w 'static /robots.txt -> HTTP %{http_code}\n' -u "admin:$PW" http://127.0.0.1/robots.txt
sudo grep -c 'Permission denied' /srv/phpapp/logs/nginx-error.log || echo 0
The file returns 200 and the error-log count is 0. The www-data account nginx runs as is
a member of the phpapp group, which is what lets it read the application tree while other
still has no access at all.
Step 5 - Open the application in a browser
Browse to http://<your-vm-public-ip>/. Your browser will prompt for a username and password — use admin and the password from Step 3.
The landing page is rendered by PHP on every request. The counters are read from the bundled PostgreSQL database, so reloading advances the hit count.

Further down, the runtime and the bundled database report themselves:

Step 6 - The bundled database, the queue and the scheduler
The application is wired to a real database with real background processing. Confirm the schema:
sudo -u postgres psql -tAd phpapp -c 'SELECT migration FROM migrations ORDER BY id'
Push some work onto the queue and watch a separate worker process consume it:
PW=$(sudo awk -F= '/^PHPAPP_ADMIN_PASSWORD=/{print substr($0,index($0,"=")+1)}' /root/laravel-credentials.txt)
for i in 1 2 3; do curl -s -o /dev/null -u "admin:$PW" http://127.0.0.1/api/dispatch; done
sleep 10
sudo -u postgres psql -d phpapp -c "SELECT kind, count(*) AS rows, max(created_at) AS latest FROM cloudimg_events GROUP BY kind ORDER BY kind"
You will see two kinds of row. queue rows were written by phpapp-queue.service, a separate process that pulled each job off the database queue. schedule rows were written by the framework scheduler, which phpapp-scheduler.timer runs once a minute. Neither is written by the web request itself, so their presence is proof both background units are genuinely working.
Confirm the worker drained the queue:
sudo -u postgres psql -tAd phpapp -c 'SELECT count(*) FROM jobs'

Step 7 - Confirm the security posture
Anonymous requests are refused everywhere except the load-balancer probe:
curl -s -o /dev/null -w 'anonymous / -> %{http_code}\n' http://127.0.0.1/
curl -s -o /dev/null -w 'anonymous /api/info -> %{http_code}\n' http://127.0.0.1/api/info
curl -s -o /dev/null -w 'unauthenticated /health -> %{http_code}\n' http://127.0.0.1/health
The first two return 401 and the probe returns 200.
The environment file is never served, even to an authenticated client:
PW=$(sudo awk -F= '/^PHPAPP_ADMIN_PASSWORD=/{print substr($0,index($0,"=")+1)}' /root/laravel-credentials.txt)
curl -s -o /dev/null -w '/.env -> %{http_code}\n' -u "admin:$PW" http://127.0.0.1/.env
Now prove PHP-FPM and PostgreSQL really are loopback-only. A refused connection on its own proves nothing — an unreachable address refuses everything — so this also checks that the same address answers on port 80:
IP=$(hostname -I | awk '{print $1}')
for P in 9000 5432; do
curl -s -o /dev/null -m 5 "http://$IP:$P/" 2>/dev/null
echo " http://$IP:$P/ -> curl exit=$? (7 = connection refused)"
done
curl -s -o /dev/null -w " http://$IP/ -> HTTP %{http_code} (positive control: the address IS live)\n" "http://$IP/"
Both ports refuse with exit 7 while nginx answers 401 on the very same address. The refusals are therefore a property of the ports, not of the address.
The database also rejects a wrong password over TCP:
if PGPASSWORD='definitely-the-wrong-password' \
psql -h 127.0.0.1 -U phpapp -d phpapp -tAc 'SELECT 1' >/dev/null 2>&1; then
echo "UNEXPECTED: the database accepted a wrong password"
else
echo "the database rejected the wrong password, as expected"
fi
Step 8 - The real-client path
The framework builds absolute URLs from the incoming request, so nginx forwards the Host header verbatim, including any port. If it forwarded a port-stripped host instead, every absolute URL would be wrong for clients arriving through a tunnel, a load balancer, or any front door that is not port 80.
Check it with hosts the server has never seen, one of them on a non-default port:
PW=$(sudo awk -F= '/^PHPAPP_ADMIN_PASSWORD=/{print substr($0,index($0,"=")+1)}' /root/laravel-credentials.txt)
for H in "example.internal:8443" "another-name.invalid" "$(hostname -I | awk '{print $1}'):9443"; do
echo -n " Host: $H -> "
curl -s -u "admin:$PW" -H "Host: $H" http://127.0.0.1/api/info | grep -o '"selfUrl": "[^"]*"'
done
Each response echoes back exactly the host and port the client asked for. The same holds for redirects:
PW=$(sudo awk -F= '/^PHPAPP_ADMIN_PASSWORD=/{print substr($0,index($0,"=")+1)}' /root/laravel-credentials.txt)
curl -s -o /dev/null -D - -u "admin:$PW" -H 'Host: example.internal:8443' http://127.0.0.1/absolute-redirect | grep -i '^location:'

Reached from a client using a different name on a non-default port, the page reports that name and port back:

Step 9 - Deploy your own application
The shipped application is a working starting point, not a fixture. Composer is installed, so you can manage dependencies on the machine itself.
The application root is /srv/phpapp/app, owned root:phpapp with only storage and bootstrap/cache writable by the service account. To deploy your own code, put it there, apply its migrations, and restart the runtime:
cd /srv/phpapp/app
sudo -u phpapp php artisan migrate --force
sudo systemctl restart php8.3-fpm phpapp-queue
Add a route and see it served immediately — PHP has no build step:
sudo tee -a /srv/phpapp/app/routes/web.php >/dev/null <<'ROUTE'
Route::get('/hello-from-my-app', fn () => response()->json([
'message' => 'This route was added on the running VM',
'php' => PHP_VERSION,
]));
ROUTE
sudo chown root:phpapp /srv/phpapp/app/routes/web.php
sleep 3
PW=$(sudo awk -F= '/^PHPAPP_ADMIN_PASSWORD=/{print substr($0,index($0,"=")+1)}' /root/laravel-credentials.txt)
curl -s -u "admin:$PW" http://127.0.0.1/hello-from-my-app
The application tree is owned root:phpapp and is deliberately not writable by the
service account, so deploying code is done as root (or your own deploy user) and the files
are handed back to the phpapp group to be read. The sleep lets PHP's opcache notice the
changed file.
Remove the demonstration route again:
sudo sed -i '/hello-from-my-app/,+4d' /srv/phpapp/app/routes/web.php
sudo chown root:phpapp /srv/phpapp/app/routes/web.php
sleep 3
If you change anything in .env, rebuild the cached configuration so the running application picks it up:
cd /srv/phpapp/app && sudo -u phpapp php artisan config:cache
Step 10 - Check the versions and the licensing
Everything in this image comes from the Ubuntu archive or from Packagist, and the exact versions are recorded on the machine:
php --version | head -2
cd /srv/phpapp/app && php artisan --version
grep ^PINNED /opt/cloudimg/laravel-versions.txt
The third-party notices ship with the image, including the per-package licence manifest for every Composer dependency:
head -20 /srv/phpapp/notices/NOTICE.txt
head -5 /opt/cloudimg/laravel-composer-licences.txt

Step 11 - Change the admin password
The admin login is an nginx HTTP Basic credential. To change it:
PW=$(sudo awk -F= '/^PHPAPP_ADMIN_PASSWORD=/{print substr($0,index($0,"=")+1)}' /root/laravel-credentials.txt)
sudo htpasswd -b /etc/nginx/.phpapp_htpasswd admin "$PW"
sudo systemctl reload nginx
That re-applies the current password, so you can run it safely as a check. To set a new
one, replace "$PW" with your chosen password and update /root/laravel-credentials.txt to
match, so the machine's own record stays accurate.
Maintenance
Security updates. unattended-upgrades is enabled, so the OS keeps patching itself. PHP, nginx and PostgreSQL are all served by the Ubuntu security pocket.
Backups. Everything you own is under /srv/phpapp. Snapshot the data disk, or dump the database:
sudo -u postgres pg_dump -Fc phpapp > /var/tmp/phpapp-backup.dump
ls -lh /var/tmp/phpapp-backup.dump
Logs.
sudo tail -n 20 /srv/phpapp/logs/nginx-error.log 2>/dev/null || echo "no nginx errors logged"
sudo journalctl -u phpapp-queue -n 20 --no-pager
TLS. The image serves plain HTTP. Put TLS in front before exposing it to an untrusted network — either an Azure Application Gateway or Front Door, or a certificate terminated by nginx on this VM. The application already trusts X-Forwarded-Proto from the local reverse proxy, so HTTPS is reflected correctly in the URLs it emits once you terminate TLS.
Restarting the stack.
sudo systemctl restart php8.3-fpm nginx phpapp-queue
systemctl is-active php8.3-fpm nginx phpapp-queue
Support
cloudimg images are backed by 24/7 support. Contact support@cloudimg.co.uk with the offer name and your VM's region and size.