Application Servers Azure

cloudimg CodeIgniter 4 Host on Ubuntu 24.04 on Azure User Guide

| Product: cloudimg CodeIgniter 4 Host on Ubuntu 24.04 LTS on Azure

Overview

This image is a ready-to-run host for CodeIgniter 4 applications. It is not a bare framework download: PHP 8.3-FPM and CodeIgniter 4.7.4 are both installed at pinned versions, a real application is already serving through nginx, and a single-node MariaDB runs alongside it on the same machine. Everything you own — the application tree, its writable/ directory and the database files — lives on a dedicated data volume mounted at /srv/codeigniter, separate from the operating system disk.

Read this before you expose the VM. PHP-FPM is bound to 127.0.0.1:9000 and MariaDB to 127.0.0.1:3306; neither can be reached from the network. nginx on port 80 is the only public listener, and it requires HTTP Basic authentication on every path except /health, using a password generated uniquely on the first boot of every VM. Neither nginx nor PHP-FPM starts at all until that first boot has completed, so there is no window in which the machine is reachable without a credential, and no default password exists in the image.

CodeIgniter runs in production mode. The framework's development environment mounts a debug toolbar and renders full stack traces — including file paths, SQL and environment variables — to anybody who can reach the page. This image ships CI_ENVIRONMENT=production and the build fails closed if that ever changes.

What is included:

  • CodeIgniter 4.7.4, pinned exactly and verified against the framework's own version constant
  • PHP 8.3 with the intl, mbstring, mysqli, curl, xml and zip extensions, from the Ubuntu 24.04 archive, checksum-verified at build time against the GPG-signed package index
  • MariaDB 10.11, bound to loopback, with its data directory on the dedicated volume
  • nginx on port 80 as the only public listener, enforcing HTTP Basic authentication on every path
  • A per-VM administrator password, database password and CodeIgniter encryption.key, all generated on first boot and recorded in a root-only file
  • A working application with a real database migration that runs on first boot
  • A dedicated 20 GiB data volume at /srv/codeigniter for your application and its data
  • An unauthenticated /health endpoint for Azure Load Balancer health probes
  • 24/7 cloudimg support

CodeIgniter is a trademark of the CodeIgniter Foundation. cloudimg is not affiliated with, endorsed by, or sponsored by the CodeIgniter Foundation.

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 reasonable starting point; size up for heavier applications. NSG inbound: allow 22/tcp from your management network and 80/tcp for the application. The image serves plain HTTP on port 80; for production, terminate TLS in front of it with your own domain.

Step 1 - Deploy from the Azure Marketplace

Sign in to the Azure Portal, choose Create a resource, search the Marketplace for cloudimg CodeIgniter 4 Host, 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 -> Create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group my-resource-group \
  --name my-codeigniter-host \
  --image cloudimg:codeigniter:default:latest \
  --size Standard_B2s \
  --storage-sku StandardSSD_LRS \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_rsa.pub \
  --public-ip-sku Standard

az vm open-port --resource-group my-resource-group --name my-codeigniter-host --port 80

Accept the image terms once per subscription before the first deployment:

az vm image terms accept --publisher cloudimg --offer codeigniter --plan default

Step 3 - Retrieve the per-VM credentials

Every VM generates its own secrets on first boot. SSH in and read them:

sudo cat /root/codeigniter-credentials.txt

The file is 0600 root:root and holds the site URL, the administrator username and password, and the database name, user and password. The screenshot below also shows the first-boot journal, including the migration being applied and the authentication check the service performs before it declares success.

The first-boot service result, its journal showing the migration applied and authentication verified, and the per-VM credentials file with its root-only permissions

Confirm first boot completed:

systemctl is-active codeigniter-firstboot.service
test -f /var/lib/cloudimg/codeigniter-firstboot.done && echo "first boot complete"

Step 4 - Check the services and the listeners

Four units make up the stack. MariaDB and PHP-FPM are loopback-only; nginx is the only public listener.

for u in mariadb.service php8.3-fpm.service nginx.service codeigniter-firstboot.service; do
  printf '%-34s %s\n' "$u" "$(systemctl is-active $u)"
done
ss -H -ltn | awk '{print $4}' | sort -u

You should see 127.0.0.1:9000 (PHP-FPM) and 127.0.0.1:3306 (MariaDB) bound to loopback, and *:80 or 0.0.0.0:80 for nginx. Prove the private ports really are closed on the routable address, while nginx answers on the same address:

IP=$(hostname -I | awk '{print $1}')
curl -s -o /dev/null -m 5 "http://${IP}:9000/" ; echo "PHP-FPM on ${IP}:9000 -> curl exit $? (7 means refused)"
curl -s -o /dev/null -m 5 "http://${IP}:3306/" ; echo "MariaDB on ${IP}:3306 -> curl exit $? (7 means refused)"
curl -s -o /dev/null -w "nginx on ${IP}:80 -> HTTP %{http_code} (401 means it is live and asking for a password)\n" -m 10 "http://${IP}/"

The last line is the important one: it is the positive control. Without it, a refused connection would prove nothing, because an unreachable address refuses everything.

The four services reporting active, the listener table showing PHP-FPM and MariaDB bound to loopback only, and the anonymous request being refused with 401 while the health probe returns 200

Step 5 - Open the application

Browse to http://<your-vm-ip>/ and sign in with admin and the password from Step 3. The landing page is server-rendered by CodeIgniter on every request.

The application landing page showing the live request counter, the pinned CodeIgniter, PHP and MariaDB versions, the production environment, and the Host header the request arrived with

The counter is the point. It is not a page view number held in memory: it is a row in a MariaDB table created by a CodeIgniter migration, read and written on every request. Reload the page and it advances — that single fact proves the migration ran, the per-VM database credential authenticates, and both the read and the write path work.

The same page after a reload, with the request counter advanced from 20 to 21, proving the database read and write round trip

The same facts are available as JSON for monitoring:

curl -s -u "admin:<CI_ADMIN_PASSWORD>" http://127.0.0.1/api/info

The /api/info endpoint returning the framework, PHP and database versions, the environment, the base URL and the request counter as JSON

Without a password, nothing is reachable except the health probe:

curl -s -o /dev/null -w "anonymous  -> HTTP %{http_code}\n" http://127.0.0.1/
curl -s -o /dev/null -w "health     -> HTTP %{http_code}\n" http://127.0.0.1/health

The application refusing an unauthenticated browser request with 401 Authorization Required

Step 6 - The migration and the database

The shipped migration runs automatically on first boot. Inspect it and the table it creates:

cd /srv/codeigniter/app && sudo -u www-data -E php spark migrate:status
sudo mysql -t -e 'DESCRIBE codeigniter.stack_counters;'

The migration status showing CreateStackCounters applied in batch 1, the resulting table schema, and the request counter advancing between two consecutive requests

Your own migrations go in /srv/codeigniter/app/app/Database/Migrations/ and are applied the same way:

cd /srv/codeigniter/app && sudo -u www-data -E php spark migrate

Connect to the database directly with the credentials from Step 3:

sudo mysql -e 'SHOW DATABASES;'

Step 7 - Deploy your own application

The image is a host, so replacing the shipped application with your own is the normal path. The application root is /srv/codeigniter/app and nginx serves /srv/codeigniter/app/public.

Add a controller and a route, exactly as you would on any CodeIgniter install:

sudo tee /srv/codeigniter/app/app/Controllers/GuideDemo.php >/dev/null <<'PHP'
<?php
namespace App\Controllers;

class GuideDemo extends BaseController
{
    public function index()
    {
        return 'Hello from my own CodeIgniter controller';
    }
}
PHP
sudo tee -a /srv/codeigniter/app/app/Config/Routes.php >/dev/null <<'PHP'
$routes->get('guide-demo', 'GuideDemo::index');
PHP
sudo php -l /srv/codeigniter/app/app/Config/Routes.php
sudo systemctl reload php8.3-fpm.service

Note the quoted here-document (<<'PHP'). $routes must reach the file literally. Writing the line with echo "$routes->get(...)" instead lets the shell expand $routes to nothing, appends a bare ->get(...) and leaves Routes.php unparseable — which takes down every route on the site, not just the new one. The php -l above is there to catch exactly that before you reload.

Fetch it back to prove the framework picked it up:

curl -s -u "admin:<CI_ADMIN_PASSWORD>" http://127.0.0.1/guide-demo | grep -q 'Hello from my own CodeIgniter controller' \
  && echo "OK: the framework served the newly added route" \
  || { echo "FAILED: the new route did not serve"; exit 1; }

Remove the demo again:

sudo rm -f /srv/codeigniter/app/app/Controllers/GuideDemo.php
sudo sed -i "/guide-demo/d" /srv/codeigniter/app/app/Config/Routes.php
sudo systemctl reload php8.3-fpm.service

To install your own codebase, put it in /srv/codeigniter/app, keep the existing .env (it holds this VM's database password and encryption key), install dependencies and reload.

Run composer as root, not as www-data. Application code in this image is deliberately root-owned and not writable by the web user, so that a compromised PHP process cannot rewrite the code it is executing. Only writable/ is writable by www-data. Composer needs to write vendor/, so it runs as root and the ownership is re-asserted afterwards:

cd /srv/codeigniter/app
sudo COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader --no-interaction 2>&1 | tail -3
sudo chown -R root:root vendor
sudo find vendor -type d -exec chmod 0755 {} +
sudo find vendor -type f -exec chmod 0644 {} +
sudo systemctl reload php8.3-fpm.service

Running composer install as www-data instead will fail with Permission denied writing vendor/composer/autoload_classmap.php. That is the ownership model working as intended, not a fault.

Use --no-dev in production: it keeps test and static-analysis tooling out of the deployed tree. Keep writable/ owned by www-data, and everything else owned by root.

Step 8 - Configure your own domain

app.baseURL in /srv/codeigniter/app/.env is what CodeIgniter uses to build every link, redirect and asset URL. First boot sets it to this VM's address. If you put a domain, a load balancer or a TLS terminator in front of the machine, update two settings:

sudo grep -E '^(app.baseURL|CI_ALLOWED_HOSTNAMES)' /srv/codeigniter/app/.env

Set app.baseURL to the address your users type, and add that hostname to CI_ALLOWED_HOSTNAMES (a comma-separated list). CodeIgniter deliberately ignores a Host header that is not in that list and falls back to app.baseURL — that is what stops an attacker poisoning your generated links by sending a forged Host. After editing, reload PHP-FPM:

sudo systemctl reload php8.3-fpm.service

Step 9 - Versions and provenance

Every version this image ships is recorded and re-asserted at boot.

grep -E '^PINNED_' /opt/cloudimg/codeigniter-versions.txt
cd /srv/codeigniter/app && php -r '$l=json_decode(file_get_contents("composer.lock"),true); foreach($l["packages"] as $p) printf("%-26s %-10s %s\n",$p["name"],$p["version"],implode(",",(array)$p["license"]));'

The redistributed runtime tree is three packages: codeigniter4/framework (MIT), laminas/laminas-escaper (BSD-3-Clause) and psr/log (MIT). The framework additionally vendors Kint (MIT), PSR Log (MIT) and Laminas Escaper (BSD-3-Clause) under system/ThirdParty/, and every one of those licence files ships with the image as those licences require.

The pinned CodeIgniter, PHP and MariaDB versions, the count of checksum-verified PHP packages, and the three-package permissive runtime tree from composer.lock

Step 10 - Storage layout

findmnt -no SOURCE,TARGET,FSTYPE,SIZE /srv/codeigniter
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/mysql

/srv/codeigniter is the dedicated data volume. /var/lib/mysql is a bind mount onto /srv/codeigniter/mysql, so the database files live on that same volume while the path MariaDB's AppArmor profile expects is unchanged. The volume is deliberately not under /mnt, because Azure mounts its ephemeral resource disk there and its contents do not survive a deallocation.

df -h /srv/codeigniter | tail -1

Troubleshooting

The site returns 401 and you do not have the password. Read it from the VM: sudo cat /root/codeigniter-credentials.txt. It is unique per VM and is never in the image.

Nothing listens on port 80. First boot has not finished or has failed. nginx and PHP-FPM are gated on /var/lib/cloudimg/codeigniter-bootstrap-ready, which first boot creates only after it has minted and verified this VM's credential — so a failed first boot leaves the machine closed rather than open. Check it:

systemctl status codeigniter-firstboot.service --no-pager | head -12
sudo journalctl -u codeigniter-firstboot.service -n 40 --no-pager

Links point at the wrong host. Set app.baseURL and CI_ALLOWED_HOSTNAMES as described in Step 8.

A 500 error with no detail. That is production mode working as intended. The detail is in the application log rather than the browser:

sudo tail -30 /srv/codeigniter/app/writable/logs/log-$(date +%Y-%m-%d).log 2>/dev/null || echo "no application log for today"

Check the whole stack at once. The image ships the same prover the build gate uses:

sudo /usr/local/sbin/codeigniter-credential-round-trip.sh

Support

cloudimg provides 24/7 support for this image. Contact support@cloudimg.co.uk.