Deployment guide

ExpressionEngine

Overview

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 the way your site needs. A template engine then presents it.

It also adds members with roles and granular permissions, categories and relationships, a file manager, multiple site support, and a full control panel that a content team can run without touching a configuration file.

This guide covers deploying and configuring ExpressionEngine on Ubuntu 24.04 on Azure using SecureAnyCloud Azure Marketplace images. The image installs ExpressionEngine 7.5.26 from the official upstream release archive. It uses nginx and PHP 8.3 FPM to serve the site and MariaDB from Ubuntu 24.04 to hold the site data, and it wires all three together. Unattended security upgrades 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 exposed to the network. Port 80 is the only reachable surface.

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

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 first boot of every VM, a one-shot service does the following:

  1. Generates a unique encryption_key and session_crypt_key, a unique database password and a unique administrator password.
  2. Runs ExpressionEngine's own installer to create the database schema and the single Super Admin account, then removes the installer.
  3. Proves that 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.
  4. Writes /root/expressionengine-credentials.txt (mode 0600, root only).

Why 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 you get:

  • 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. Also open 22 for your own administration.
1

Deploy from the Azure Portal

Find the ExpressionEngine on Ubuntu 24.04 LTS by SecureAnyCloud 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. Provide your SSH public key for the azureuser account.

On the Networking tab, allow inbound 80 (and 22 for SSH). Then select Review + create.

2

Deploy from the Azure CLI

Run 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
# Accept the Marketplace terms once per subscription (required before the first CLI deploy)
az vm image terms accept --urn sac1647283583153:expressionengine-ubuntu-24-04:default:latest
az vm create \
  --resource-group expressionengine-rg \
  --name expressionengine-vm \
  --image sac1647283583153:expressionengine-ubuntu-24-04:default: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 use it to reach the site.

3

First boot and your credentials

On first boot, the image generates this VM's encryption keys, database password and administrator password. It runs ExpressionEngine's installer to create the schema and the single Super Admin account, then removes the installer. It opens the bootstrap gate and starts the web server. Finally, it verifies that 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.

4

Confirm the appliance is running

All three services should report active. The ss command confirms that the site is on port 80 and MariaDB is bound to loopback only. 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
5

Sign in to the control panel

Browse to http://<your-vm-public-ip>/admin.php. The ExpressionEngine control panel sign-in form opens.

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

You can prove the same sign-in from the command line. The commands below use the per-instance password from the credentials file to complete a real login through the control panel form. They then check 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)"
6

Change the administrator password

Set your own password as soon as you sign in. Click your avatar in the top right of the control panel and open your profile. Use the password field to set a new password.

ExpressionEngine stores passwords hashed, so the new password is never recoverable from the database. After you change 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.

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, and each has 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 can filter by channel, category, status, date or author, edit an entry, or select New to publish another.

To build your own content model, open Developer and then Channels to create a channel. Open Developer and then Fields to add the custom fields it should carry. Then use Entries and New to publish into it.

Write the templates that present your channels on the public site under Developer and then Templates.

8

Your public website

Browse to http://<your-vm-public-ip>/ to see the public site rendered from the seeded demo channels and templates. It shows 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.

To confirm from the command line that the public site is serving content with no session or cookie involved, run:

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}"
9

Members, roles and permissions

Open Members to manage accounts and Roles to control what they can do. The 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 and nothing else. Then assign members to it, so a content team can work without super admin access.

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 and then Outgoing Email in the control panel.

Enter your SMTP host, port, protocol, username and password. Then send the test message from that page.

11

How the security model works

This step 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. First boot writes the marker 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/sac-bootstrap-gate.conf /etc/systemd/system/php8.3-fpm.service.d/sac-bootstrap-gate.conf systemctl is-enabled mariadb.service php8.3-fpm.service nginx.service expressionengine-firstboot.service ls -l /var/lib/sac/data/

The web root and the installer. Only index.php and admin.php may execute PHP, and the whole /system/ tree is refused. 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.

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 under Settings and then URL and Path Settings. 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 that visitors can reach the site.

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.

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 that the services are up, then read 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 its initial text, first boot has not completed. Check its progress:

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, then 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 SecureAnyCloud 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 sales@secureanycloud.com.

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.

Deploy on Azure

Launch ExpressionEngine 7.5.26 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