cloudimg CodeIgniter 4 Host on Ubuntu 24.04 on Azure User Guide
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,xmlandzipextensions, 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/codeigniterfor your application and its data - An unauthenticated
/healthendpoint 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.

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.

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 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 facts are available as JSON for monitoring:
curl -s -u "admin:<CI_ADMIN_PASSWORD>" http://127.0.0.1/api/info

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

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;'

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').$routesmust reach the file literally. Writing the line withecho "$routes->get(...)"instead lets the shell expand$routesto nothing, appends a bare->get(...)and leavesRoutes.phpunparseable — which takes down every route on the site, not just the new one. Thephp -labove 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.

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.