Applications AWS

ExpressionEngine 7.5.28 on AWS User Guide

| Product: ExpressionEngine 7.5.28 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the ExpressionEngine 7.5.28 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg BookStack, CMS, Blogs and Wikis listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

ExpressionEngine is a free and open source content management platform written in PHP. Instead of a fixed page or blog model it gives you channels and custom fields, so you model content the way your site needs, and a template language to present it. Members, roles and permissions, categories, a file manager and a full control panel at /admin.php come with it.

The cloudimg image adds:

  • No reachable installer, no shared database, no shared keys. ExpressionEngine's browser installer creates the site, its encryption keys and the first Super Admin for whoever opens it first. This image ships no database at all (the MariaDB data directory is empty), the empty configuration file ExpressionEngine itself ships, and no installer in the web root. Your instance's first boot initialises its own database and runs ExpressionEngine's own installer before the web server starts: over a temporary server on a random local port that answers only requests carrying a token generated for that boot. The installer creates the schema, the default site theme, the Super Admin admin with a password generated on your instance and the two keys that sign every session and cookie (encryption_key and session_crypt_key). First boot then stops that server and removes the installer again.
  • Secrets of your instance's own, never on a command line. The database password, the admin password, the encryption keys and the TLS key are all generated on your instance; nothing from the image build is reused. The installer's form, which carries the passwords, is handed over in files only root can read, never as command line arguments, and its reply is never logged.
  • The current, verified release. ExpressionEngine 7.5.28 is the newest stable release and includes the security fixes of 7.5.27 and 7.5.28. The download is checked against a pinned checksum and against the release's own signature with ExpressionEngine's update-signing key.
  • No calls home. A stock ExpressionEngine checks expressionengine.com for new versions while you use the control panel, loads a news feed on the dashboard and can share analytics, and the control panel's scripts post your site's address to ExpressionEngine's licence service from your browser on every page. On this image the version check, the news feed and analytics are off, and the control panel's pages may only talk to your own server (Step 7), so the server makes none of those calls while you use the control panel.
  • No third-party scripts on your site. ExpressionEngine's default site theme loads an old jQuery (1.11.2) from Google's CDN in every visitor's browser; on this image it loads the jQuery 3.6.0 (with jQuery Migrate) that ExpressionEngine itself ships, from your own server. The unused old jQuery 2.2.4 copy is removed.
  • HTTPS from the first boot. nginx serves ExpressionEngine on port 443 with a certificate generated on your instance's first boot. Port 80 redirects to HTTPS.
  • Fail closed. nginx does not start until first boot has finished; if first boot cannot complete, the site stays offline rather than open.
  • Only ExpressionEngine's entry points run PHP. The site goes through index.php and the control panel through admin.php; every other PHP file is never run, and the whole system folder (configuration, framework, templates and caches) is never served. The folders PHP can write to that the web serves, images and themes/user (which holds the default theme and its upload folders), are static only: every file gets nosniff and a sandboxing content security policy, and only passive types (images, PDF, fonts, audio and video) open in the browser; anything else, SVG and HTML included, is sent as a download. PHP in templates is off.
  • A rate-limited sign in. nginx gives every request that sends data to ExpressionEngine (the control panel sign in, the forgotten password form, the front-end member sign in and every other form) one shared budget of 30 a minute from one address after a burst of 30, and ExpressionEngine locks out an address after four wrong passwords for a user in a minute. nginx uses the address of the connection, never a header a client sends.
  • The real host and client address. nginx hands ExpressionEngine the host name you configure and nothing a request claims, and the configuration pins the site and control panel addresses to it, so every link, password reset e-mails included, uses your host name. nginx removes X-Forwarded-For, Client-IP and the other forwarding headers before PHP sees them, so ExpressionEngine records the address of the connection.
  • Hardened defaults. The ExpressionEngine code belongs to root and is read only to PHP, the configuration file is read only to PHP, and only ExpressionEngine's runtime folders are writable (and kept private to the web server, not world readable). PHP cannot run shell commands and is confined to the site folder. MariaDB has no network listener at all (ExpressionEngine talks to it over a local socket) and its root account signs in only through the socket as the operating system's root. nginx, PHP, MariaDB and the first boot service are blocked from the instance metadata service, the AMI requires IMDSv2, and requests for a host name the instance does not serve are dropped.
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.

What is on the image: ExpressionEngine 7.5.28 (the upstream release, checksum and signature verified), MariaDB 10.11, PHP 8.3 FPM, nginx, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.

Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet (or a private route you use for management).
  • A security group that allows TCP 22 (SSH), TCP 443 (ExpressionEngine) and TCP 80 (redirects to HTTPS) from the networks that should reach them. Keep 22 to your own networks. Open 443 to your visitors for a public site, or only to your team while you build it.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick ExpressionEngine 7.5.28 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large, your key pair and a security group as above.

With the AWS CLI (replace the values in angle brackets):

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=expressionengine}]'

Step 2: Connect to your instance

Version SSH user
ExpressionEngine 7.5.28 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the services

First boot takes under a minute (it creates the database and runs ExpressionEngine's installer). Then nginx, PHP, MariaDB and the first boot unit are active:

systemctl is-active nginx php8.3-fpm mariadb expressionengine-firstboot
active
active
active
active

nginx serves ports 80 and 443; PHP and MariaDB listen on local sockets only, and the installer's temporary server is gone, so the only open ports are 22, 80 and 443:

sudo ss -tlnp | awk 'NR==1 || /nginx|sshd|php|maria/' | awk '{print $1, $4, $6}' | sed -E 's/,pid=[0-9]+,fd=[0-9]+//g'
State Local Peer
LISTEN 0.0.0.0:22 users:(("sshd"),("systemd"))
LISTEN 0.0.0.0:80 users:(("nginx"),("nginx"),("nginx"))
LISTEN 0.0.0.0:443 users:(("nginx"),("nginx"),("nginx"))
LISTEN [::]:22 users:(("sshd"),("systemd"))
LISTEN [::]:80 users:(("nginx"),("nginx"),("nginx"))
LISTEN [::]:443 users:(("nginx"),("nginx"),("nginx"))

The ExpressionEngine release and the PHP and MariaDB versions:

grep "define('APP_VER'" /var/www/expressionengine/system/ee/legacy/libraries/Core.php | sed 's/^ *//'
php -v | head -1
mariadb --version
define('APP_VER', '7.5.28');
PHP 8.3.6 (cli) (built: Oct 19 2026 15:16:58) (NTS)
mariadb  Ver 15.1 Distrib 10.11.14-MariaDB, for debian-linux-gnu (x86_64) using  EditLine wrapper

How the download was verified:

cat /usr/share/doc/expressionengine/RELEASE
ExpressionEngine 7.5.28
source https://github.com/ExpressionEngine/ExpressionEngine/releases/download/7.5.28/ExpressionEngine7.5.28.zip
commit cc658dd16f555ac76c064324c20a99bc93a7ad7b
sha256 0d830ba2cc908ef67d89dff8cf70e677859de29c34a32b21dab810de0bccc57c
signature verified with ExpressionEngineUpdate.pub sha256 dbebeba87e57e3bab14913ad0aa39ccee5138f040911555f1f5d49f7b4bf9335

Step 4: Get your sign-in details

The credentials file holds the address ExpressionEngine is served as (your instance's public DNS name by default), the control panel address, the Super Admin login and password, and the database credentials, all generated on this instance. Show it with:

sudo cat /root/expressionengine-credentials.txt
EE_URL=https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/
EE_ADMIN_URL=https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/admin.php
EE_ADMIN_USER=admin
EE_ADMIN_PASSWORD=<24 letters and digits>
EE_DB_NAME=expressionengine
EE_DB_USER=expressionengine
EE_DB_PASSWORD=<48 hexadecimal characters>

Step 5: Visit the site and sign in

Open EE_URL in your browser. The certificate is self signed (generated on your instance), so accept the browser warning once, or install your own certificate (Step 11). A fresh site shows ExpressionEngine's default theme, a small sample site with a blog:

The ExpressionEngine default theme home page of a fresh site

Open EE_ADMIN_URL and sign in with the user name admin and EE_ADMIN_PASSWORD:

The ExpressionEngine control panel sign in page with the Username and Password fields

The control panel opens on its Overview dashboard, with the menu on the left:

The ExpressionEngine control panel Overview dashboard with the sidebar menu

admin is a Super Admin. Create a separate account for each editor under Members and give it a role (Roles, under Members in the menu) with only the permissions it needs.

The control panel shows Super Admins a warning that the configuration file "does not have the proper permissions" and suggests mode 666. That is deliberate: do not make config.php world writable (Step 12 explains why, and how to save the few settings that live in it).

Step 6: Channels, entries and templates

Content lives in channels; each channel has its own fields. The default theme comes with the channels About, Blog and Contact. Open Entries to list, edit and publish entries, or Entries > Blog > + for a new blog post:

The ExpressionEngine control panel Entries list of the default theme's channels

Channels and Fields (under Developer in the menu) are where you model your own content, and Templates is where you write the templates that present it on the site (the default theme's templates are in the home, blog, about and contact groups). Files and images you upload go into upload folders you manage under Files.

The default theme is sample content: its 404 page shows an animated image hosted on giphy.com, and its blog entry template can embed YouTube, SoundCloud and Bandcamp players when an entry has them. Edit or remove those templates under Templates when you build your own site.

Step 7: The closed-by-default settings

cloudimg sets these as ExpressionEngine configuration overrides in system/user/config/config.php:

Setting Image default What it does
new_version_check n ExpressionEngine would otherwise ask expressionengine.com for the newest version while you use the control panel. Settings > General Settings > Check now still checks on demand.
show_ee_news n The dashboard would otherwise load ExpressionEngine's news feed from expressionengine.com.
share_analytics n No usage analytics are sent to ExpressionEngine.
allow_php n Templates cannot run PHP, whatever a template's own setting says.
cookie_secure y Session and CSRF cookies are only sent over HTTPS.
proxy_ips empty ExpressionEngine trusts no forwarded client address (Step 10).
password_lockout, password_lockout_interval y, 1 Four wrong passwords for a user from one address lock that address out for a minute.
base_url, site_url, cp_url https://<your host>/ Every link, including password reset e-mails, uses your host name (Step 11 changes it).
sudo grep -E "^\\\$config\['(new_version_check|show_ee_news|share_analytics|allow_php|cookie_secure|proxy_ips|password_lockout|password_lockout_interval)'\]" /var/www/expressionengine/system/user/config/config.php
$config['new_version_check'] = 'n';
$config['show_ee_news'] = 'n';
$config['share_analytics'] = 'n';
$config['allow_php'] = 'n';
$config['cookie_secure'] = 'y';
$config['proxy_ips'] = '';
$config['password_lockout'] = 'y';
$config['password_lockout_interval'] = '1';

The control panel also sends a content security policy that lets its pages connect only to your own server. Without it the control panel's scripts post your site's name and address, the ExpressionEngine version and your add-on list from your browser to updates.expressionengine.com on every control panel page you open (ExpressionEngine's licence and add-on check):

H=$(sed -n 's/^set \$ee_host "\(.*\)";$/\1/p' /etc/nginx/expressionengine-host.conf)
curl -sk -D - -o /dev/null --resolve "$H:443:127.0.0.1" "https://$H/admin.php?/cp/login" | tr -d '\r' | grep -i '^content-security-policy'
content-security-policy: connect-src 'self'; frame-ancestors 'self'

If you add commercial add-ons that check their licences from the control panel, or use ExpressionEngine Pro, remove that add_header line from /etc/nginx/sites-available/cloudimg-expressionengine and reload nginx. If you switch the version check or the news feed back on, the server needs outbound HTTPS to expressionengine.com.

Step 8: Your data

Everything that makes your site lives in three places: the expressionengine database in MariaDB, your templates in /var/www/expressionengine/system/user/templates, and the uploads under /var/www/expressionengine/images and /var/www/expressionengine/themes/user. ExpressionEngine's caches in system/user/cache are rebuilt automatically. Keep system/user/config/config.php with your backup: it holds this site's encryption keys and database password.

Back up the database to a file only root can read (MariaDB's root signs in over the local socket without a password, so no password appears on the command line):

sudo sh -c 'umask 077; mysqldump --single-transaction expressionengine > /root/expressionengine-$(date +%F).sql'
sudo tar -C /var/www/expressionengine -czf /root/expressionengine-files-$(date +%F).tgz system/user/config system/user/templates images themes/user

ExpressionEngine's own database backup (Tools > Utilities > Back Up Database) also works. For a whole-instance copy, snapshot the EBS volume.

Step 9: Security checks

Port 80 sends browsers to HTTPS, the health endpoint answers for load balancer checks, and the installer, the configuration, the command line tool, the templates, the bundled libraries and stray PHP files in the upload folders are not reachable:

H=$(sed -n 's/^set \$ee_host "\(.*\)";$/\1/p' /etc/nginx/expressionengine-host.conf)
curl -s -o /dev/null -w 'port 80: %{http_code} -> %{redirect_url}\n' --resolve "$H:80:127.0.0.1" "http://$H/admin.php"
curl -sk --resolve "$H:443:127.0.0.1" "https://$H/healthz"
for p in /system/ee/installer/ /system/user/config/config.php /system/ee/eecli.php /system/user/templates/ /system/ee/vendor-build/composer/installed.json /themes/user/x.php /images/uploads/x.php; do
  curl -sk -o /dev/null -w "$p: %{http_code}\n" --resolve "$H:443:127.0.0.1" "https://$H$p"
done
curl -s -o /dev/null -w 'unknown host name: %{http_code}\n' -H 'Host: example.com' http://127.0.0.1/ || true
port 80: 301 -> https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/admin.php
ok
/system/ee/installer/: 404
/system/user/config/config.php: 404
/system/ee/eecli.php: 404
/system/user/templates/: 404
/system/ee/vendor-build/composer/installed.json: 404
/themes/user/x.php: 404
/images/uploads/x.php: 404
unknown host name: 000

(000 means nginx closed the connection without an answer.) The credentials file is readable by root only, the configuration file by root and the web server, ExpressionEngine's runtime and upload folders belong to the web server, and the code belongs to root:

sudo stat -c '%a %U:%G %n' /root/expressionengine-credentials.txt /var/www/expressionengine/system/user/config/config.php /var/www/expressionengine/system/user/cache /var/www/expressionengine/system/user/templates /var/www/expressionengine/themes/user /var/www/expressionengine/images/uploads /var/www/expressionengine/admin.php
600 root:root /root/expressionengine-credentials.txt
640 root:www-data /var/www/expressionengine/system/user/config/config.php
750 www-data:www-data /var/www/expressionengine/system/user/cache
750 www-data:www-data /var/www/expressionengine/system/user/templates
750 www-data:www-data /var/www/expressionengine/themes/user
750 www-data:www-data /var/www/expressionengine/images/uploads
644 root:root /var/www/expressionengine/admin.php

An uploaded HTML file is sent as a download with nosniff and the sandboxing policy, never rendered as a page of your site:

H=$(sed -n 's/^set \$ee_host "\(.*\)";$/\1/p' /etc/nginx/expressionengine-host.conf)
echo '<b>demo</b>' | sudo -u www-data tee /var/www/expressionengine/images/uploads/guide-demo.html >/dev/null
curl -sk -D - -o /dev/null --resolve "$H:443:127.0.0.1" "https://$H/images/uploads/guide-demo.html" | tr -d '\r' | grep -iE '^(HTTP|content-disposition|content-security-policy|x-content-type)'
sudo rm -f /var/www/expressionengine/images/uploads/guide-demo.html
HTTP/2 200
x-content-type-options: nosniff
content-security-policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox
content-disposition: attachment

The certificate was generated on this instance and names its own addresses:

H=$(sed -n 's/^set \$ee_host "\(.*\)";$/\1/p' /etc/nginx/expressionengine-host.conf)
echo | openssl s_client -connect 127.0.0.1:443 -servername "$H" 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName
subject=CN = ec2-<public-ip-dashed>.<region>.compute.amazonaws.com, O = cloudimg
X509v3 Subject Alternative Name:
    DNS:ec2-<public-ip-dashed>.<region>.compute.amazonaws.com, IP Address:<public-ip>, IP Address:<private-ip>, IP Address:127.0.0.1, DNS:localhost

nginx, PHP, MariaDB and the first boot service (and the installer's temporary server, which ran inside it) cannot reach the instance metadata service; PHP cannot run shell commands and is confined to the site folder; nginx hands PHP the configured host name and removes forwarding headers and any Authorization header a client sends, and rate-limits requests:

for u in nginx php8.3-fpm mariadb expressionengine-firstboot; do systemctl show $u -p IPAddressDeny; done
php-fpm8.3 -i 2>/dev/null | grep -E '^(open_basedir|disable_functions|expose_php) ' | cut -d' ' -f1-3
grep -E 'fastcgi_param (HTTP_HOST|SERVER_NAME|HTTP_X_FORWARDED_FOR|HTTP_CLIENT_IP|HTTP_X_FORWARDED_HOST|HTTP_AUTHORIZATION) ' /etc/nginx/snippets/cloudimg-expressionengine-fastcgi.conf
grep -h 'limit_req_zone\|limit_req_status' /etc/nginx/conf.d/cloudimg-expressionengine-guard.conf
IPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32
IPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32
IPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32
IPAddressDeny=fd00:ec2::254/128 169.254.169.254/32 168.63.129.16/32
disable_functions => exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
expose_php => Off
open_basedir => /var/www/expressionengine/:/var/lib/php/sessions/:/tmp/
fastcgi_param HTTP_HOST $ee_host;
fastcgi_param SERVER_NAME $ee_host;
fastcgi_param HTTP_X_FORWARDED_HOST "";
fastcgi_param HTTP_X_FORWARDED_FOR "";
fastcgi_param HTTP_CLIENT_IP "";
fastcgi_param HTTP_AUTHORIZATION "";
limit_req_zone $ee_post_key zone=ee_post:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=ee_php:10m rate=300r/m;
limit_req_status 429;

The ee_post limit counts every request that sends data (any method other than GET and HEAD) to index.php or admin.php from one address: signing in, the forgotten password form, front-end member forms and saving your work in the control panel, 30 a minute after a burst of 30; ee_php allows 300 PHP requests a minute. Raise them in /etc/nginx/conf.d/cloudimg-expressionengine-guard.conf and /etc/nginx/snippets/cloudimg-expressionengine-fastcgi.conf if your editors need more. Because Authorization headers are removed, ExpressionEngine's template HTTP authentication is off; restrict templates to member roles instead. If four wrong passwords lock you out ("You are only permitted to make four login attempts every 1 minute(s)"), wait a minute or run sudo expressionengine-set-password (Step 12), which clears the lockout.

sudo expressionengine-cred-roundtrip checks the whole sign in path on the instance: the redirect to HTTPS, that no installer is offered and the configuration is refused, a sign in through the control panel's login form with the password from the credentials file, and that a wrong and a common password are refused:

sudo expressionengine-cred-roundtrip
OK https 301 + no installer + config.php 404 + admin login + wrong and default passwords refused

Links opened from another site. A control panel link that arrives from another site (an e-mail in webmail, a page elsewhere) can reach only /admin.php, the sign in and forgotten password pages, the password reset link and the dashboard; anything else in the control panel (sign out, sidebar and banner switches, the update check, every other page) needs the request to come from the control panel itself, so another site cannot sign you out or trigger an action through a link. The same goes for the front-end editing toggle of ExpressionEngine Pro's dock. nginx decides this from the browser's Sec-Fetch-Site, Origin and Referer headers (a request with none of them is refused too); a refused link shows a page with a link back into the control panel. Scripts that call the control panel must send Sec-Fetch-Site: same-origin:

H=$(sed -n 's/^set \$ee_host "\(.*\)";$/\1/p' /etc/nginx/expressionengine-host.conf)
curl -sk -o /dev/null -w 'sign out link from another site: %{http_code}\n' -H 'Sec-Fetch-Site: cross-site' --resolve "$H:443:127.0.0.1" "https://$H/admin.php?/cp/login/logout"
curl -sk -o /dev/null -w 'sign in page from another site: %{http_code}\n' -H 'Sec-Fetch-Site: cross-site' --resolve "$H:443:127.0.0.1" "https://$H/admin.php?/cp/login"
sign out link from another site: 403
sign in page from another site: 200

The rules are in /etc/nginx/conf.d/cloudimg-expressionengine-xsite.conf, which expressionengine-set-hostname writes for your host name and the add-on actions installed at the time (run it again after installing an add-on whose links must work from other sites).

Step 10: Behind a proxy or load balancer

nginx removes X-Forwarded-For, X-Real-IP, Client-IP and Forwarded from every request, and proxy_ips is empty, so ExpressionEngine's logs, sessions and lockouts use the address of the connection. Behind an AWS load balancer that is the load balancer's address; nginx's own access log (/var/log/nginx/access.log) records it the same way. To record visitors' addresses, use nginx's real_ip module with set_real_ip_from set to your load balancer's subnet only.

Step 11: Your own DNS name and certificate

Point a DNS record at the instance, then set the name ExpressionEngine is served as. This mints a new self signed certificate for the name, updates nginx (which hands the name to ExpressionEngine) and the site, control panel and theme addresses in config.php, clears ExpressionEngine's caches, updates the addresses in /root/expressionengine-credentials.txt and keeps the name across reboots:

sudo expressionengine-set-hostname <your-domain>

To use a certificate from your own certificate authority instead, copy it over the generated pair and reload nginx (running expressionengine-set-hostname again replaces it with a self signed one):

sudo install -m 0644 <your-domain>.crt /etc/nginx/ssl/expressionengine.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/ssl/expressionengine.key
sudo nginx -t && sudo systemctl reload nginx

Once browsers trust the certificate you can also add an HSTS header (add_header Strict-Transport-Security "max-age=31536000" always;) to /etc/nginx/snippets/cloudimg-expressionengine-headers.conf. The image does not send one: browsers ignore HSTS from a site whose certificate they do not trust.

Step 12: Passwords, settings, e-mail, add-ons and upgrades

expressionengine-set-password gives admin a new random password with ExpressionEngine's own password hashing, clears a sign in lockout, signs out every session of the account and records the password in /root/expressionengine-credentials.txt. Run it without arguments, or pass your own password (24 to 96 letters and digits) on stdin. Members change their own password in the control panel under My Profile (their account menu).

sudo expressionengine-set-password
printf '%s\n' '<your-new-password>' | sudo expressionengine-set-password --stdin

The configuration file is read only to PHP. config.php is executed on every request, so the image does not let the web server rewrite it (a compromised PHP process could otherwise change the site permanently). ExpressionEngine keeps most settings in its database and saves them normally; a few live in config.php, and saving one of those reports that the configuration file is not writable. For that change only, make the file writable, save the setting, then put it back:

sudo chmod 0660 /var/www/expressionengine/system/user/config/config.php
sudo chmod 0640 /var/www/expressionengine/system/user/config/config.php

E-mail. ExpressionEngine sends password resets and notifications with PHP's mail function by default; the image has no mail server, so set an SMTP server under Settings > Outgoing Email before you rely on e-mail.

Add-ons. The code folders belong to root, so install add-ons from the shell: copy the add-on into /var/www/expressionengine/system/user/addons/<name> (and any theme files into themes/user, owned by www-data), then install it under Add-Ons.

Upgrades. ExpressionEngine releases are published at https://github.com/ExpressionEngine/ExpressionEngine/releases with a signature.txt. After a snapshot, follow ExpressionEngine's documented manual update: lay the new release's files over the site (keep system/user, images and themes/user), let its system/ee/installer run the database update through the control panel, then remove system/ee/installer again, give system/user/cache back to www-data and check that config.php is 0640 root:www-data once more.

Step 13: Server components

Component Path
ExpressionEngine code (root owned) /var/www/expressionengine
Configuration (this instance's keys and database password) /var/www/expressionengine/system/user/config/config.php
File and folder modes ExpressionEngine writes with (0640 / 0750) /var/www/expressionengine/system/user/config/constants.php
Templates /var/www/expressionengine/system/user/templates
Uploads and the site theme /var/www/expressionengine/images, /var/www/expressionengine/themes/user
Caches (rebuilt automatically) /var/www/expressionengine/system/user/cache
Database (MariaDB datadir) /var/lib/mysql
nginx site, request limits and cross-site link rules /etc/nginx/sites-available/cloudimg-expressionengine, /etc/nginx/conf.d/cloudimg-expressionengine-guard.conf, /etc/nginx/conf.d/cloudimg-expressionengine-xsite.conf, /etc/nginx/snippets/cloudimg-expressionengine-*.conf
PHP settings /etc/php/8.3/fpm/conf.d/99-cloudimg-expressionengine.ini
MariaDB settings /etc/mysql/mariadb.conf.d/99-cloudimg-expressionengine.cnf
Certificate and key /etc/nginx/ssl/
First boot script, installer copy and helpers /usr/local/sbin/expressionengine-firstboot.sh, /usr/local/lib/cloudimg/expressionengine/
Helpers /usr/local/sbin/expressionengine-set-hostname, expressionengine-set-password, expressionengine-cred-roundtrip
Licence, release and image notes /usr/share/doc/expressionengine/
Credentials /root/expressionengine-credentials.txt (mode 0600)

Step 14: Security recommendations

  • Allow port 22 in the security group from your own networks only.
  • Keep the admin password secret and rotate it if it may have been shared (Step 12). Give each editor their own account with only the permissions they need.
  • Keep config.php and your database backups secret (Step 8): the encryption keys in config.php sign every session and cookie of your site.
  • Use your own DNS name and a certificate your browsers trust (Step 11).
  • Keep ExpressionEngine on the newest release (Step 12) and install only add-ons you trust.
  • Keep the operating system patched; unattended security upgrades are on, and sudo apt-get update && sudo apt-get upgrade applies everything else. MariaDB, PHP-FPM and PHP's zip extension come from Ubuntu's universe repository; sudo pro security-status --esm-apps shows whether Ubuntu Pro (ESM Apps) has security updates for them (none were pending when the image was built).

Support and licensing

ExpressionEngine is free software under the Apache License, Version 2.0 (a copy is at /usr/share/doc/expressionengine/LICENSE); the bundled libraries carry their own open source licences. There is no per user or per CPU licence fee, and no licence key is needed (ExpressionEngine Pro is a separate paid add-on that this image does not include). This image installs the unmodified upstream release, without its unused copy of jQuery 2.2.4, plus the cloudimg first boot, settings and helpers described above. The AMI price covers cloudimg's hardening, first boot setup and support.

  • Email: support@cloudimg.co.uk
  • Website: https://www.cloudimg.co.uk
  • Support: 24/7, with a 24 hour response SLA