Applications Azure

ExpressionEngine on Ubuntu 24.04 on Azure User Guide

| Product: ExpressionEngine 7.5.26 on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and configuration of ExpressionEngine on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. ExpressionEngine is a flexible, free and open source content management platform published at expressionengine.com. Instead of forcing content into a fixed blog or page model it gives you channels and custom fields, so you model content exactly the way your site needs, then a powerful template engine to present it. It adds members with roles and granular permissions, categories and relationships, a file manager, multiple site support, and a full control panel a content team can run without touching a configuration file.

The image installs ExpressionEngine 7.5.26 from the official upstream release archive, nginx and PHP 8.3 FPM to serve it, and MariaDB from Ubuntu 24.04 to hold the site data, and wires all three together. Unattended security upgrades are configured to keep the server patched on your running VM.

One appliance, three services. nginx and php8.3-fpm serve the site on port 80, and mariadb holds every entry, template, member and setting. MariaDB is bound to 127.0.0.1:3306 and PHP FPM listens on a Unix socket, so neither is ever exposed to the network. Port 80 is the only reachable surface.

Only two front controllers execute PHP. The web root is the ExpressionEngine tree, and only index.php (the public site) and admin.php (the control panel) are permitted to run PHP. Every other .php URL — a nested path, or a file uploaded through the control panel — is refused rather than executed. The whole /system/ tree, including system/user/config/config.php which holds this VM's encryption keys and database password, is refused outright by the web server.

Security by design — there is no administrator account in the image. Rather than shipping a default login and rotating it, this image ships no account, an empty configuration file, no encryption keys and no database schema at all. On the very first boot of every VM a one shot service generates a unique encryption_key and session_crypt_key, a unique database password and a unique administrator password, runs ExpressionEngine's own installer to create the database schema and the single Super Admin account, removes the installer, and then proves the new password signs in through the real control panel login form and that admin/admin, a blank password and other common guesses do not, before writing /root/expressionengine-credentials.txt (mode 0600, root only).

Why the per instance encryption keys matter. ExpressionEngine's encryption_key and session_crypt_key sign every session and cookie. If they were baked into the image, every customer of that image would share them, and anyone holding the image could forge a signed session for anyone else's site. Here they are generated on your VM, for your VM, by ExpressionEngine's own installer.

The site cannot serve an unprovisioned instance. nginx and php8.3-fpm are each gated on a bootstrap marker that first boot writes only after every credential is in place and the installer has been removed. Until that marker exists systemd skips those units entirely, so there is no window in which a half provisioned site — or the setup wizard — is reachable. The units are still enabled, so the site comes straight back after a reboot.

What is included:

  • ExpressionEngine 7.5.26 served by nginx and PHP 8.3 FPM, with only index.php and admin.php permitted to execute PHP

  • MariaDB (mariadb.service) holding the site data, bound to loopback

  • A seeded demo site (channels, entries and templates) so the front end renders a real website the moment you browse to it

  • Per instance secret generation on first boot, with the credentials written to a root only file

  • A hardened nginx allowlist that refuses the entire /system/ tree and never discloses the server version

Prerequisites

  • An Azure subscription with permission to create virtual machines

  • The Azure CLI installed and signed in (az login), if you deploy from the command line

  • An SSH key pair for administrative access to the VM

  • Inbound TCP port 80 open to your audience (and 443 if you add TLS in Step 12), plus 22 for your own administration

Step 1: Deploy from the Azure Portal

Find the ExpressionEngine on Ubuntu 24.04 LTS by cloudimg offer in the Azure Marketplace and select Create. Choose a resource group and region, size the VM (a 2 vCPU / 4 GB size such as Standard_B2s is a fine starting point), and provide your SSH public key for the azureuser account. On the Networking tab, allow inbound 80 (and 22 for SSH). Review and create.

Step 2: Deploy from the Azure CLI

The same deployment from the command line. Replace the image URN, resource group and location as appropriate:

az group create --name expressionengine-rg --location eastus

az vm create \
  --resource-group expressionengine-rg \
  --name expressionengine-vm \
  --image <publisher>:<offer>:<sku>:latest \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

az vm open-port --resource-group expressionengine-rg --name expressionengine-vm --port 80

Note the public IP address that az vm create prints — you will use it to reach the site.

Step 3: First boot and your credentials

On first boot the image generates this VM's encryption keys, database password and administrator password, runs ExpressionEngine's installer to create the schema and the single Super Admin account, removes the installer, opens the bootstrap gate, starts the web server, verifies the new credentials work and the common defaults are rejected, and writes /root/expressionengine-credentials.txt. This completes within a minute or two. SSH in as azureuser and read the details:

sudo cat /root/expressionengine-credentials.txt

The file is mode 0600 and owned by root, so only a privileged user can read it. It contains the site URL, the control panel URL, the administrator username, email address and password, and the database name, user and password.

Step 4: Confirm the appliance is running

All three services should report active. ss confirms the site is on port 80 while MariaDB is bound to loopback only, and the response headers carry no server version banner.

systemctl is-active mariadb.service php8.3-fpm.service nginx.service
php -r 'echo "PHP ".PHP_VERSION."\n";'
mysql --version
nginx -v
ss -tlnp | grep -E ':(80|3306) ' | sed 's/users:.*//' | sort
curl -sI http://127.0.0.1/ | head -4

All three services report active, PHP 8.3 MariaDB and nginx report their versions, ss shows the website on port 80 while MariaDB is bound to 127.0.0.1 port 3306 only, and the response headers carry no version banner

Step 5: Sign in to the control panel

Browse to http://<your-vm-public-ip>/admin.php and you will see the ExpressionEngine control panel sign in form.

The ExpressionEngine control panel sign in form with the username and password fields

Sign in with the username admin and the password from the credentials file. The control panel dashboard opens, showing the ExpressionEngine version, welcome cards, the comments widget, and the full navigation down the left — Entries, Files, Categories, Members, Roles, Channels, Fields, Templates, Tools, Add-Ons and Settings.

The ExpressionEngine control panel dashboard showing the 7.5.26 version, welcome cards, a comments widget, and the content, members and developer navigation down the left side

You can prove the same sign in from the command line. This uses the per instance password from the credentials file to complete a real login through the control panel form, then checks that common default passwords are refused:

sudo /usr/local/sbin/expressionengine-login-check.sh admin '<EE_ADMIN_PASSWORD>' && echo "per-VM password: ACCEPTED (correct)"
sudo /usr/local/sbin/expressionengine-login-check.sh admin 'admin' || echo "admin/admin: REJECTED (correct)"
sudo /usr/local/sbin/expressionengine-login-check.sh admin '' || echo "blank password: REJECTED (correct)"

The credentials file listing proving mode 600 owned by root root, the encryption_key and session_crypt_key confirmed present and unique per VM, the real control panel login form rejecting admin/admin, admin/password, admin/expressionengine, admin/123456 and a blank password, and exactly one member existing with the Super Admin role

Step 6: Change the administrator password

The first thing to do after signing in is set your own password. Click your avatar in the top right of the control panel and open your profile, then use the password field to set a new one. ExpressionEngine stores passwords hashed, so the new password is never recoverable from the database. Once you have changed it, the copy in /root/expressionengine-credentials.txt is stale — keep the file for the database password, or remove the administrator password line from it.

Step 7: Model your content with channels, fields and entries

ExpressionEngine organises content into channels (a channel is a content type — for example News, Pages, or Products), each with its own set of custom fields. The seeded demo site ships three channels (About, Blog and Contact) and a set of entries so you have a working example to learn from.

Open Entries in the control panel to see and manage every piece of content. From here you filter by channel, category, status, date or author, edit an entry, or select New to publish another.

The ExpressionEngine Entries manager listing the seeded demo entries with their channel, author, open status and comment counts, and a New button to publish more

To build your own content model, open Developer → Channels to create a channel, Developer → Fields to add the custom fields it should carry, then use Entries → New to publish into it. Developer → Templates is where you write the templates that present your channels on the public site.

Step 8: Your public website

Browse to http://<your-vm-public-ip>/ to see the public site rendered from the seeded demo channels and templates — a home page with navigation and a list of recent blog posts, each linking to its own page. This is the core of what ExpressionEngine does: content you manage in the control panel is rendered to visitors by your templates.

The public ExpressionEngine website showing the Default Site header, a Home About Blog Contact navigation, and a list of recent blog posts each with a title, date, author and comment count

You can confirm from the command line that the public site is genuinely serving content, with no session or cookie involved:

curl -s -o /dev/null -w 'public home page: HTTP %{http_code}\n' http://127.0.0.1/
LINKS="$(curl -s http://127.0.0.1/ | grep -ciE 'blog|about|contact' || true)"
echo "navigation and content references on the public home page: ${LINKS}"

Step 9: Members, roles and permissions

Open Members to manage accounts and Roles to control what they can do. ExpressionEngine's role system is granular: you decide which members can access the control panel, publish or edit entries in each channel, manage templates, or administer the site. Create a role such as Editor with permission to publish in your content channels but nothing else, then assign members to it, so a content team can work without super admin access.

Step 10: Configure mail so member email works

A fresh instance sends mail through the local system by default. To use your own SMTP server for member registration, password reset and notifications, open Settings → Outgoing Email in the control panel and enter your SMTP host, port, protocol, username and password, then send the test message from that page.

Step 11: How the security model works

This section explains what the image does so you can verify it yourself.

The bootstrap gate. nginx and php8.3-fpm each carry a ConditionPathExists drop-in pointing at a marker that first boot writes only after every credential exists and the installer has been removed. Until then systemd skips the units, so nothing serves an unprovisioned instance. The units are still enabled, so a reboot brings the site straight back.

grep -H ConditionPathExists /etc/systemd/system/nginx.service.d/cloudimg-bootstrap-gate.conf /etc/systemd/system/php8.3-fpm.service.d/cloudimg-bootstrap-gate.conf
systemctl is-enabled mariadb.service php8.3-fpm.service nginx.service expressionengine-firstboot.service
ls -l /var/lib/cloudimg/

The nginx and php-fpm bootstrap gate drop-ins both pointing at the same bootstrap-ready marker, all units reported enabled so the appliance survives a reboot, and the marker that first boot writes after every credential exists

The web root and the installer. Only index.php and admin.php may execute PHP, the whole /system/ tree is refused, and the web installer has been removed from the tree and is unreachable:

for p in /system/user/config/config.php /system/ee/installer/ /images/uploads/x.php; do
  printf '%-34s HTTP %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' -m 10 http://127.0.0.1$p)"
done
curl -sL -o /dev/null -w 'admin.php (control panel)   HTTP %{http_code}\n' http://127.0.0.1/admin.php
curl -s  -o /dev/null -w '/ (the site)                HTTP %{http_code}\n' http://127.0.0.1/

Each protected path returns 403 or 404; the control panel and the site return 200.

The nginx web root set to the ExpressionEngine tree, config.php refused, the /system/ tree returning 404, the installer directory removed and returning 404, and only the control panel and the site returning 200

Step 12: Use your own domain and add TLS

Point an A record for your domain at the VM's public IP. Then set the control panel and site URLs to your domain in Settings → URL and Path Settings, and install a certificate with Let's Encrypt:

sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d www.your-domain.com

certbot obtains a certificate, rewrites the nginx site to serve HTTPS, and installs a renewal timer. Open 443 in your Azure network security group so visitors can reach it.

Step 13: Back up your site

A complete backup is the MariaDB database plus the ExpressionEngine tree (which holds your templates, uploaded files and configuration). Read the database name from the credentials file:

DB=$(sudo sed -n 's/^expressionengine.db.name=//p' /root/expressionengine-credentials.txt)
sudo mysqldump --single-transaction "$DB" | gzip > ~/expressionengine-db-$(date +%F).sql.gz
sudo tar czf ~/expressionengine-files-$(date +%F).tar.gz -C /var/www expressionengine
ls -lh ~/expressionengine-db-*.sql.gz ~/expressionengine-files-*.tar.gz

Copy both files off the VM (for example to Azure Blob Storage) and keep them somewhere safe.

Step 14: Keeping ExpressionEngine up to date

The operating system keeps itself patched through unattended security upgrades. For ExpressionEngine itself, watch the ExpressionEngine releases and follow the upstream upgrade instructions when a new version ships. Always back up (Step 13) before upgrading.

Troubleshooting

The site or control panel returns 502 or does not load. Check the services are up and look at the logs:

systemctl status nginx.service php8.3-fpm.service mariadb.service --no-pager
sudo tail -n 50 /var/log/nginx/error.log

First boot has not finished. If the credentials file still shows the placeholder text, first boot has not completed. Watch it:

systemctl status expressionengine-firstboot.service --no-pager
sudo journalctl -u expressionengine-firstboot.service --no-pager | tail -n 40

Locked out of the control panel. ExpressionEngine locks an account after several failed sign in attempts. Wait a few minutes and try again with the correct password from the credentials file.

A database error in the control panel. Confirm MariaDB is running and the application user can connect:

DB=$(sudo sed -n 's/^expressionengine.db.name=//p' /root/expressionengine-credentials.txt)
sudo mysqladmin ping
sudo mysql -N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB'"

Support

This image is provided by cloudimg with 24/7 technical support. For help with deployment, first boot configuration, domain and TLS setup, SMTP mail, channels and templates, members and roles, backups, performance tuning or upgrades, contact support@cloudimg.co.uk. Critical issues receive a one hour average response time.

ExpressionEngine is free software licensed under the Apache License, Version 2.0. All product and company names are trademarks or registered trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them.