Deployment guide

PHP web App host

Overview

This SecureAnyCloud image is a ready-to-run host for PHP web applications, not a bare interpreter. A working application built on the Laravel framework is already serving, with a single-node PostgreSQL database on the same machine and nginx in front as the single public entry point.

What you get:

  • PHP-FPM and the PHP CLI, installed and pinned
  • A working Laravel application, already serving
  • A bundled single-node PostgreSQL database
  • nginx as the single public entry point
  • A queue worker and the framework scheduler, running 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:

  • /srv/phpapp/app: the PHP application
  • /srv/phpapp/db: the PostgreSQL cluster
  • /srv/phpapp/notices: third-party license 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:9000 and PostgreSQL on 127.0.0.1:5432. Step 7 proves this 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 SecureAnyCloud PHP Web App Host on Ubuntu 24.04 and choose Create. Select a size.

Standard_B2s (2 vCPU, 4 GiB) is the recommended minimum, and it 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 image terms accept --urn sac1647283583153:laravel:default:latest
az vm create \
  --resource-group <your-resource-group> \
  --name <your-vm-name> \
  --image sac1647283583153:laravel: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

The file lists the application URL, the username admin, the admin password, and the PostgreSQL database name, user and password. All of them are unique to this machine. The file is 0600 root:root, so only root can read it.

Confirm the permissions, and confirm 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, which confirms that 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 that 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. This is the core 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 this path works, because it carries 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 that nginx runs as is a member of the phpapp group. That membership lets nginx 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 prompts for a username and password. Enter admin and the password from Step 3.

PHP renders the landing page on every request. The counters are read from the bundled PostgreSQL database, so reloading advances the hit count.

Further down the page, the runtime and the bundled database report their own versions and status.

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 sac_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.

The web request itself writes neither kind, so their presence proves that both background units are working.

Confirm that 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 that PHP-FPM and PostgreSQL really are loopback-only. A refused connection on its own proves nothing, because an unreachable address refuses everything. This check therefore also confirms 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 nginx 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 this 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:'

When a client reaches the application under a different name on a non-default port, the page reports that same 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, because 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. Deploy code as root or as your own deploy user, then hand the files back to the phpapp group so they can 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 that 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. The exact versions are recorded on the machine:

php --version | head -2 cd /srv/phpapp/app && php artisan --version grep ^PINNED /opt/sac/laravel-versions.txt

The third-party notices ship with the image, including the per-package license manifest for every Composer dependency:

head -20 /srv/phpapp/notices/NOTICE.txt head -5 /opt/sac/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

This re-applies the current password, so you can run it safely as a check. To set a new password, replace "$PW" with your chosen password. 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. Read the nginx error log and the queue worker's journal:

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 of it before you expose it to an untrusted network. Use an Azure Application Gateway or Front Door, or terminate a certificate in nginx on this VM. The application already trusts X-Forwarded-Proto from the local reverse proxy, so once you terminate TLS, HTTPS is reflected correctly in the URLs it emits.

Restarting the stack. Restart the services and confirm that they are active:

sudo systemctl restart php8.3-fpm nginx phpapp-queue systemctl is-active php8.3-fpm nginx phpapp-queue

Support

SecureAnyCloud images are backed by 24/7 support. Contact sales@secureanycloud.com with the offer name and your VM's region and size.

Deploy on Azure

Launch SecureAnyCloud PHP Web App Host on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure