Deployment guide

CodeIgniter 4 host

Overview

CodeIgniter 4 is a PHP web framework. This SecureAnyCloud image is a ready-to-run host for CodeIgniter 4 applications, not a bare framework download. PHP 8.3-FPM and CodeIgniter 4.7.4 are installed at pinned versions, a real application already serves through nginx, and a single-node MariaDB runs on the same machine.

Everything you own lives on a dedicated data volume mounted at /srv/codeigniter, separate from the operating system disk. That includes the application tree, its writable/ directory and the database files.

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, so neither can be reached from the network. nginx on port 80 is the only public listener. 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 until that first boot has completed. The machine is never 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 you get:

  • 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 and 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 SecureAnyCloud support

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

Prerequisites

You need an active Azure subscription, an SSH key pair, and a VNet with a subnet in the target region.

Standard_B2s (2 vCPU / 4 GiB RAM) is a reasonable starting size. Choose a larger size for heavier applications.

Configure the network security group (NSG) to allow inbound 22/tcp from your management network and 80/tcp for the application. The image serves plain HTTP on port 80, so 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 SecureAnyCloud CodeIgniter 4 Host, and select Create.

On the Basics tab, 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).

Select Review + create, then Create.

Step 2 - Deploy from the Azure CLI

Run the following to create the VM and open port 80:

az vm create \
  --resource-group my-resource-group \
  --name my-codeigniter-host \
  --image sac1647283583153: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 sac --offer codeigniter --plan default

Step 3 - Retrieve the per-VM credentials

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

sudo cat /root/codeigniter-credentials.txt

The file is 0600 root:root. It holds the site URL, the administrator username and password, and the database name, user and password.

Next, confirm that first boot completed. The first-boot service applies the migration and checks authentication before it declares success:

systemctl is-active codeigniter-firstboot.service test -f /var/lib/sac/data/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, and nginx is the only public listener. Check that each unit is active:

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.

Now prove that the private ports 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 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. CodeIgniter renders the landing page on the server for every request.

The counter is the proof of a working stack. It is not a page-view number held in memory. It is a row in a MariaDB table created by a CodeIgniter migration, and it is read and written on every request.

Reload the page and the counter advances. That single fact proves that 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. Verify this:

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

Put your own migrations in /srv/codeigniter/app/app/Database/Migrations/ and apply them the same way:

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

To connect to the database directly, use the credentials from Step 3:

sudo mysql -e 'SHOW DATABASES;'

Step 7 - Deploy your own application

The image is a host, so you normally replace the shipped application with your own. 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'). The text $routes must reach the file literally.

If you write the line with echo "$routes->get(...)" instead, the shell expands $routes to nothing and appends a bare ->get(...). That leaves Routes.php unparseable and takes down every route on the site, not just the new one. The php -l check above catches this before you reload.

Fetch the route to prove that 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 controller and route 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, which holds this VM's database password and encryption key. Then 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 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

If you run composer install as www-data instead, it fails 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 to keep 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

CodeIgniter builds every link, redirect and asset URL from app.baseURL in /srv/codeigniter/app/.env. 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. Show their current values:

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

Set app.baseURL to the address your users type. Add that hostname to CI_ALLOWED_HOSTNAMES, which is a comma-separated list.

CodeIgniter ignores a Host header that is not in that list and falls back to app.baseURL. This stops an attacker from poisoning your generated links with a forged Host. After editing, reload PHP-FPM:

sudo systemctl reload php8.3-fpm.service

Step 9 - Check versions and provenance

The image records every version it ships and re-asserts them at boot. List the pinned versions:

grep -E '^PINNED_' /opt/sac/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 also vendors Kint (MIT), PSR Log (MIT) and Laminas Escaper (BSD-3-Clause) under system/ThirdParty/. Every one of those licence files ships with the image, as the licences require.

Step 10 - Confirm the storage layout

Confirm the mounts for the data volume and the MariaDB data directory:

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 and the path that MariaDB's AppArmor profile expects is unchanged.

The volume is deliberately not under /mnt. Azure mounts its ephemeral resource disk there, and its contents do not survive a deallocation. Check the free space:

df -h /srv/codeigniter | tail -1

Troubleshooting

The site returns 401 and you do not have the password. Read it from the VM with sudo cat /root/codeigniter-credentials.txt. The password 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 wait for /var/lib/sac/data/codeigniter-bootstrap-ready, which first boot creates only after it has created and verified this VM's credential. A failed first boot therefore leaves the machine closed rather than open. Check the first-boot service:

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. This is production mode working as intended. The detail is in the application log, not in 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 credential round-trip check that the image build uses:

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

Support

SecureAnyCloud provides 24/7 support for this image. Contact sales@secureanycloud.com.

Deploy on Azure

Launch SecureAnyCloud CodeIgniter 4 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