Applications AWS

WordPress 6.9 on AWS User Guide

| Product: WordPress 6.9 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the WordPress 6.9 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.

WordPress is the free and open source publishing platform and content management system written in PHP: posts and pages written in the block editor, block themes you design in the Site Editor, media, comments, users with roles, and thousands of plugins and themes. This image runs WordPress 6.9.11 (the current 6.9 security release) with the Twenty Twenty-Five theme, served by nginx and PHP-FPM 8.3 from a local MariaDB 10.11 database.

The cloudimg image adds:

  • No reachable installer, no shared database. WordPress's browser installer (wp-admin/install.php) creates the site and the first administrator for whoever opens it first. This image ships no database (the MariaDB data directory is empty), no wp-config.php and no reachable installer. Your instance's first boot creates the database and runs WordPress's own installer through WP-CLI, so the site is yours before the web server starts.
  • Secrets of your instance's own, never on a command line. The database password, the administrator password, WordPress's eight authentication keys and salts and the TLS key are generated on your instance; nothing from the image build is reused.
  • HTTPS from the first boot. nginx serves WordPress 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.
  • The Classic editor's TinyMCE is off by default. WordPress 6.9 still bundles TinyMCE 4.9.11 for the Classic editor, and that version has published stored cross-site scripting advisories with no 4.x fix. The image keeps it unreachable: a must-use plugin turns off the Classic (visual) editor and the Classic block, and nginx refuses every TinyMCE file. The block editor is unaffected (Step 6).
  • Automatic minor security updates on. WordPress applies 6.9.x security releases by itself, and its scheduled jobs run from a system timer every 5 minutes instead of on visitors' page loads (Step 10).
  • Closed-by-default settings. XML-RPC, pingbacks and trackbacks, and application passwords are off; anonymous visitors cannot list users over the REST API, with ?author=, or from the sitemap; sign up is off; avatars (Gravatar) and the emoji script are off, so pages load nothing from third-party servers.
  • Uploads never run. Files in wp-content/uploads are served as static files only, each with nosniff and a sandboxing content security policy, and anything but images, PDF, fonts, audio and video is sent as a download. PHP runs only from WordPress itself and from wp-content/plugins and wp-content/themes.
  • Requests from other sites cannot act for you. Every request that changes something (every form submission and REST write, on any address of the site) and the admin-ajax, admin-post and oEmbed proxy endpoints only work when the request comes from your site itself (Step 9).
  • A rate-limited sign in. nginx allows 10 sign-in attempts a minute from one address, using the address of the connection, never a header a client sends.
  • A PHP that cannot reach the shell. PHP cannot run shell commands, fork, change its environment, load extensions or use FFI, in the web server and in WP-CLI alike; nothing written by the web server under /var/www, /tmp or the session folder can be executed; and the web server's PHP is confined to the site with open_basedir.
  • WP-CLI as the web server user. sudo wp ... runs WP-CLI as www-data (the owner of the site files) in its own sandbox, never as root, so a plugin's code never runs with root's rights.
  • Hardened defaults. MariaDB has no network listener (WordPress 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, first boot, the scheduled jobs and WP-CLI are blocked from the EC2 instance metadata service.
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet (WordPress checks api.wordpress.org for updates).
  • A security group that allows TCP 22 (SSH), TCP 443 (WordPress) 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 WordPress 6.9 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=wordpress}]'

Step 2: Connect to your instance

Version SSH user
WordPress 6.9 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 WordPress's installer). Then nginx, PHP, MariaDB, the first boot unit and the timer for WordPress's scheduled jobs are active:

systemctl is-active nginx php8.3-fpm mariadb wordpress-firstboot wordpress-cron.timer
active
active
active
active
active

nginx serves ports 80 and 443; PHP and MariaDB listen on local sockets only, 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 WordPress release, the PHP and MariaDB versions, and how the downloads were verified:

sudo wp core version --extra
php -v | head -1
mariadb --version
cat /usr/share/doc/cloudimg-wordpress/RELEASE
WordPress version: 6.9.11
Database revision: 60717
TinyMCE version:   4.9110 (49110-20250317)
Package language:  en_US
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
WordPress 6.9.11
source https://wordpress.org/wordpress-6.9.11.tar.gz
sha256 b870d073d15d4573373bcee961b9eecec8b1063aabf26335bd964898b0d7710a
sha1 38e46b10acb2fe37ab20930f222075b9ee7e080d
WP-CLI 2.12.0 (https://github.com/wp-cli/wp-cli/releases/download/v2.12.0/wp-cli-2.12.0.phar, sha256 ce34ddd838f7351d6759068d09793f26755463b4a4610a5a5c0a97b68220d85c)
theme: Twenty Twenty-Five 1.5 (https://downloads.wordpress.org/theme/twentytwentyfive.1.5.zip, sha256 f333ce53aaa4049639298247d03480a4bd9b56fa8f0fea0440da262d7d595ba4)
not shipped: Akismet, Hello Dolly, Twenty Twenty-Three, Twenty Twenty-Four
option B (TinyMCE 4.9.11 off by default): /usr/local/lib/cloudimg/wordpress/mu-plugins/cloudimg-wp-no-tinymce.php + /etc/nginx/snippets/cloudimg-wp-no-tinymce.conf

Step 4: Get your sign-in details

The credentials file holds the address WordPress is served as (your instance's public DNS name by default), the administration address, the administrator's user name, e-mail and password, and the database credentials, all generated on this instance. Show it with:

sudo cat /root/wordpress-credentials.txt
WORDPRESS_URL=https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/
WORDPRESS_ADMIN_URL=https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/wp-admin/
WORDPRESS_ADMIN_USER=cloudimg
WORDPRESS_ADMIN_PASSWORD=<24 letters and digits>
WORDPRESS_ADMIN_EMAIL=admin@example.com
WORDPRESS_DB_NAME=wordpress
WORDPRESS_DB_USER=wordpress
WORDPRESS_DB_PASSWORD=<48 letters and digits>

The administrator starts with the e-mail admin@example.com and the display name Site Administrator; change both to your own under Users > Profile before you rely on WordPress's password e-mails. Its public author name (site-admin) is not the sign-in name.

Step 5: Visit the site and sign in

Open WORDPRESS_URL in your browser. The certificate is self signed (generated on your instance), so your browser warns once: compare the certificate's SHA-256 fingerprint with the one on the instance (sudo openssl x509 -in /etc/nginx/ssl/wordpress.crt -noout -fingerprint -sha256) and continue, or install a certificate from your own certificate authority (Step 12). A fresh site shows the Twenty Twenty-Five theme with WordPress's Hello world! post:

The home page of a fresh WordPress site in the Twenty Twenty-Five theme

Open WORDPRESS_ADMIN_URL and sign in with WORDPRESS_ADMIN_USER and WORDPRESS_ADMIN_PASSWORD:

The WordPress sign in page with the user name and password fields

The dashboard opens with WordPress's welcome panel and the admin menu on the left:

The WordPress dashboard with the welcome panel, Site Health, Quick Draft and At a Glance

The dashboard may say that a newer major WordPress release (7.x) is available. A major upgrade is your decision, not an automatic update (Step 10).

Step 6: Write with the block editor (the Classic editor is off)

Posts lists your posts (a fresh site has WordPress's Hello world!); Add Post opens a new one:

The WordPress Posts list with the Hello world! post

Posts and pages open in the block editor: add paragraphs, headings, images, galleries, columns, buttons and patterns from the + inserter:

The WordPress block editor with a title, a paragraph block and a heading block

Appearance > Editor opens the Site Editor, where you design the theme's templates, header, footer, colours and fonts with the same blocks:

The WordPress Site Editor showing the Twenty Twenty-Five theme

Why there is no Classic editor. WordPress 6.9 still ships TinyMCE 4.9.11, the editor behind the Classic (visual) editor and the Classic block. TinyMCE 4.x has published high-severity stored cross-site scripting advisories (CVE-2026-47759, CVE-2026-47761 and CVE-2026-47762) and no 4.x release fixes them; a user who can write posts could store content that runs script in an editor's browser when the Classic editor opens it. So this image keeps TinyMCE unreachable by default, in two independent layers:

  • the must-use plugin cloudimg-wp-no-tinymce (in /usr/local/lib/cloudimg/wordpress/mu-plugins, owned by root, outside the web root, with no deactivate switch in the admin area) turns the visual editor off for every user, removes the Classic block and the old editor scripts, and keeps every editing screen on the block editor;
  • nginx refuses every TinyMCE file, in every spelling, and the script loader that could bundle it.

The block editor, the Site Editor, comments and the text-mode (HTML) editors keep working. Older posts written in the Classic editor open in the block editor, where their content is kept as HTML you can convert to blocks.

sudo wp plugin list --status=must-use --fields=name,status
sudo wp theme list --fields=name,status,version
H=$(sed -n 's/^set \$wp_host "\(.*\)";$/\1/p' /etc/nginx/wordpress-host.conf)
for p in /wp-includes/js/tinymce/tinymce.min.js /wp-includes/js/tinymce/wp-tinymce.php /WP-INCLUDES/JS/TINYMCE/tinymce.min.js "/wp-admin/load-scripts.php?load%5Bchunk_0%5D=wp-tinymce" /wp-includes/js/jquery/jquery.min.js; do
  curl -sk -o /dev/null -w "$p: %{http_code}\n" --resolve "$H:443:127.0.0.1" "https://$H$p"
done
name    status
cloudimg-wp-no-tinymce  must-use
cloudimg-wordpress-hardening    must-use
name    status  version
twentytwentyfive    active  1.5
/wp-includes/js/tinymce/tinymce.min.js: 403
/wp-includes/js/tinymce/wp-tinymce.php: 403
/WP-INCLUDES/JS/TINYMCE/tinymce.min.js: 403
/wp-admin/load-scripts.php?load%5Bchunk_0%5D=wp-tinymce: 403
/wp-includes/js/jquery/jquery.min.js: 200

(403 is nginx refusing the file; jQuery, which the admin area needs, still loads.)

What re-opens the advisories (the residual). The protection covers WordPress as shipped. Re-enabling the Classic editor brings back TinyMCE 4.9.11 and its unfixed stored cross-site scripting advisories. The Classic Editor plugin uses core's TinyMCE (off while user_can_richedit is false), so re-enabling it together with removing the mu-plugin brings TinyMCE back; so does removing cloudimg-wp-no-tinymce.php or the nginx rule /etc/nginx/snippets/cloudimg-wp-no-tinymce.conf, or adding a plugin or theme that brings its own copy of TinyMCE (for example a page builder or form plugin with a visual editor). cloudimg recommends keeping the Classic editor off until WordPress ships a fixed editor. If you need it in the meantime, give only trusted users the right to write posts, and check the release notes of each WordPress upgrade (Step 10).

Step 7: The closed-by-default settings

Setting Image default Where to change it
Permalinks /%postname%/ Settings > Permalinks
Anyone can register off Settings > General
Pingbacks and trackbacks off Settings > Discussion
Avatars (Gravatar) off Settings > Discussion
Time zone UTC Settings > General
XML-RPC, application passwords off the must-use plugin cloudimg-wordpress-hardening.php
Theme and plugin file editor off (DISALLOW_FILE_EDIT) wp-config.php
Automatic core updates minor (6.9.x) releases wp-config.php (WP_AUTO_UPDATE_CORE)
sudo wp option get permalink_structure
for o in users_can_register default_ping_status default_pingback_flag show_avatars timezone_string blog_public; do printf '%s=%s\n' "$o" "$(sudo wp option get $o)"; done
sudo grep -E "^define\( '(WP_AUTO_UPDATE_CORE|DISALLOW_FILE_EDIT|DISABLE_WP_CRON|FS_METHOD|FORCE_SSL_ADMIN|WPMU_PLUGIN_DIR)'" /var/www/wp-config.php
/%postname%/
users_can_register=0
default_ping_status=closed
default_pingback_flag=0
show_avatars=0
timezone_string=UTC
blog_public=1
define( 'FORCE_SSL_ADMIN', true );
define( 'DISALLOW_FILE_EDIT', true );
define( 'DISABLE_WP_CRON', true );
define( 'WP_AUTO_UPDATE_CORE', 'minor' );
define( 'FS_METHOD', 'direct' );
define( 'WPMU_PLUGIN_DIR', '/usr/local/lib/cloudimg/wordpress/mu-plugins' );

blog_public=1 means search engines may index the site; set Settings > Reading > Search engine visibility while you build it.

Step 8: Your data

Everything that makes your site lives in the wordpress database in MariaDB and the uploads under /var/www/wordpress/wp-content/uploads; plugins and themes you add are under wp-content/plugins and wp-content/themes. Keep /var/www/wp-config.php with your backup: it holds this site's database password, keys and salts.

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 wordpress > /root/wordpress-$(date +%F).sql'
sudo tar -C /var/www -czf /root/wordpress-files-$(date +%F).tgz wp-config.php wordpress/wp-content

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 XML-RPC, the web cron trigger, the configuration, the installer, the readme, WordPress's internal PHP files, a path after index.php, PHP in uploads and dotfiles are not reachable:

H=$(sed -n 's/^set \$wp_host "\(.*\)";$/\1/p' /etc/nginx/wordpress-host.conf)
curl -s -o /dev/null -D - --resolve "$H:80:127.0.0.1" "http://$H/wp-admin/" | tr -d '\r' | awk 'NR==1 {print "port 80:", $2} tolower($1)=="location:" {print "redirects to:", $2}' | sed -E 's#https://[^/]+#https://<your host>#'
curl -sk --resolve "$H:443:127.0.0.1" "https://$H/healthz"
for p in /xmlrpc.php /wp-cron.php /wp-config.php /wp-admin/install.php /wp-admin/setup-config.php /readme.html /wp-includes/version.php /index.php/wp-json/wp/v2/users /wp-content/uploads/x.php /.git/config; do
  curl -sk -o /dev/null -w "$p: %{http_code}\n" -H 'Sec-Fetch-Site: same-origin' --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
redirects to: https://<your host>/wp-admin/
ok
/xmlrpc.php: 403
/wp-cron.php: 403
/wp-config.php: 403
/wp-admin/install.php: 403
/wp-admin/setup-config.php: 403
/readme.html: 403
/wp-includes/version.php: 403
/index.php/wp-json/wp/v2/users: 404
/wp-content/uploads/x.php: 404
/.git/config: 404
unknown host name: 000

(000 means nginx closed the connection without an answer.) Anonymous visitors cannot list your users, and a form posted from another site is refused:

H=$(sed -n 's/^set \$wp_host "\(.*\)";$/\1/p' /etc/nginx/wordpress-host.conf)
for p in /wp-json/wp/v2/users "/?rest_route=/wp/v2/users" "/?author=1" /wp-sitemap-users-1.xml; do
  curl -sk -o /dev/null -w "$p: %{http_code}\n" --resolve "$H:443:127.0.0.1" "https://$H$p"
done
curl -sk -o /dev/null -w 'POST /wp-login.php from another site: %{http_code}\n' -H 'Sec-Fetch-Site: cross-site' -d 'log=x&pwd=y' --resolve "$H:443:127.0.0.1" "https://$H/wp-login.php"
/wp-json/wp/v2/users: 401
/?rest_route=/wp/v2/users: 403
/?author=1: 404
/wp-sitemap-users-1.xml: 404
POST /wp-login.php from another site: 403

The credentials file is readable by root only, wp-config.php by root and the web server; the site files belong to the web server user so that WordPress can update itself, while the must-use plugins belong to root:

sudo stat -c '%a %U:%G %n' /root/wordpress-credentials.txt /var/www/wp-config.php /var/www /var/www/wordpress /var/www/wordpress/wp-content/uploads /var/www/wordpress/index.php /usr/local/lib/cloudimg/wordpress/mu-plugins /usr/local/lib/cloudimg/wordpress/mu-plugins/cloudimg-wp-no-tinymce.php
600 root:root /root/wordpress-credentials.txt
640 root:www-data /var/www/wp-config.php
755 root:root /var/www
750 www-data:www-data /var/www/wordpress
750 www-data:www-data /var/www/wordpress/wp-content/uploads
640 www-data:www-data /var/www/wordpress/index.php
755 root:root /usr/local/lib/cloudimg/wordpress/mu-plugins
644 root:root /usr/local/lib/cloudimg/wordpress/mu-plugins/cloudimg-wp-no-tinymce.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 \$wp_host "\(.*\)";$/\1/p' /etc/nginx/wordpress-host.conf)
sudo -u www-data install -d -m 0750 /var/www/wordpress/wp-content/uploads/guide-demo
echo '<b>demo</b>' | sudo -u www-data tee /var/www/wordpress/wp-content/uploads/guide-demo/demo.html >/dev/null
curl -sk -D - -o /dev/null --resolve "$H:443:127.0.0.1" "https://$H/wp-content/uploads/guide-demo/demo.html" | tr -d '\r' | grep -iE '^(HTTP|content-disposition|content-security-policy|x-content-type)'
sudo -u www-data rm -rf /var/www/wordpress/wp-content/uploads/guide-demo
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

WordPress and PHP are blocked from the EC2 instance metadata service (so a vulnerable plugin cannot fetch the instance's credentials), in the web stack and in WP-CLI:

for s in nginx php8.3-fpm mariadb wordpress-cron; do printf '%s: %s\n' "$s" "$(systemctl show -p IPAddressDeny --value $s.service)"; done
sudo wp eval '$r = wp_remote_get("http://169.254.169.254/latest/meta-data/", array("timeout" => 4)); echo is_wp_error($r) ? "metadata service: unreachable\n" : "metadata service: REACHABLE\n";'
nginx: 168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128
php8.3-fpm: 168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128
mariadb: 168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128
wordpress-cron: 168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128
metadata service: unreachable

Step 10: Updates and scheduled jobs

Automatic updates. WordPress installs minor (6.9.x) releases by itself, normally within 12 hours of their release: the site files belong to the web server user and WP_AUTO_UPDATE_CORE is minor. To do this WordPress contacts api.wordpress.org (and downloads.wordpress.org) twice a day; it also checks there for plugin, theme and translation updates, which it shows in Dashboard > Updates and installs only if you turn on their automatic updates. If your instance has no route to the internet, apply updates yourself.

Scheduled jobs. WordPress's scheduled jobs (update checks, publishing scheduled posts, cleanups) run every 5 minutes from wordpress-cron.timer as the web server user, instead of from visitors' page loads (wp-cron.php is not reachable):

systemctl is-enabled wordpress-cron.timer
sudo wp core check-update --minor
sudo wp cron event list --fields=hook,recurrence --format=csv | tail -n +2 | sort
enabled
Success: WordPress is at the latest version.
delete_expired_transients,"1 day"
do_pings,Non-repeating
recovery_mode_clean_expired_keys,"1 day"
wp_delete_temp_updater_backups,"1 week"
wp_privacy_delete_old_export_files,"1 hour"
wp_scheduled_auto_draft_delete,"1 day"
wp_scheduled_delete,"1 day"
wp_site_health_scheduled_check,"1 week"
wp_update_comment_type_batch,Non-repeating
wp_update_plugins,"12 hours"
wp_update_themes,"12 hours"
wp_update_user_counts,"12 hours"
wp_version_check,"12 hours"

Major upgrades. A new major release (such as 7.x) is not installed automatically. Take a snapshot, check that your plugins and theme support it, then use Dashboard > Updates or sudo wp core update. After any upgrade, check that the Classic editor is still off (Step 6).

Plugins and themes. Installing a plugin or theme from the admin area is installing code that runs with your site's rights: install only what you trust and keep it updated. The admin area cannot edit plugin or theme files (DISALLOW_FILE_EDIT).

Step 11: Behind a proxy or load balancer

nginx removes X-Forwarded-For, X-Real-IP, Client-IP, X-Forwarded, Forwarded-For and Forwarded from every request and hands WordPress your configured host name, so WordPress sees the address of the connection and never builds links from a host a request claims. Behind an AWS load balancer that address is the load balancer's, so the sign-in limit applies to all visitors together. To record visitors' addresses, use nginx's real_ip module with set_real_ip_from set to your load balancer's subnet only, and terminate HTTPS on port 443 of the instance (WordPress forces HTTPS for the admin area).

Step 12: Your own DNS name and certificate

Point a DNS record at the instance, then set the name WordPress is served as. This updates WP_HOME and WP_SITEURL in wp-config.php, rewrites the site address stored in the database, mints a new self signed certificate for the name, updates nginx and the cross-site rules, updates the addresses in /root/wordpress-credentials.txt and keeps the name across reboots:

sudo wordpress-set-hostname <your-domain>

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

sudo install -m 0644 <your-domain>.crt /etc/nginx/ssl/wordpress.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/ssl/wordpress.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-wordpress-headers.conf. The image does not send one: browsers ignore HSTS from a site whose certificate they do not trust.

Step 13: Passwords, e-mail and WP-CLI

wordpress-set-password gives the administrator a new random password, signs out every session and records the password in /root/wordpress-credentials.txt. Users change their own password under Users > Profile.

sudo wordpress-set-password

E-mail. The image has no mail server, so WordPress cannot send password reset, comment or update e-mails until you add an SMTP plugin you trust (configured with your provider, such as Amazon SES) or a local mail relay.

WP-CLI. Run it as sudo wp <command>: it runs as the web server user www-data in its own sandbox (no metadata service, no shell commands), with the same rights as the site. For example sudo wp plugin list, sudo wp user list, sudo wp core verify-checksums.

Step 14: Server components

Component Path
WordPress (owned by www-data, updated by WordPress) /var/www/wordpress
Configuration (this instance's database password, keys and salts) /var/www/wp-config.php
Uploads /var/www/wordpress/wp-content/uploads
cloudimg must-use plugins (root owned) /usr/local/lib/cloudimg/wordpress/mu-plugins/
WP-CLI and its sandboxed wrapper /usr/local/lib/cloudimg/wordpress/wp-cli.phar, /usr/local/bin/wp
Database (MariaDB datadir) /var/lib/mysql
nginx site, request limits, cross-site and TinyMCE rules /etc/nginx/sites-available/cloudimg-wordpress, /etc/nginx/conf.d/cloudimg-wordpress-guard.conf, /etc/nginx/conf.d/cloudimg-xsite-guard.conf, /etc/nginx/snippets/cloudimg-wordpress-*.conf, /etc/nginx/snippets/cloudimg-wp-no-tinymce.conf
PHP settings /etc/php/8.3/fpm/conf.d/99-cloudimg-wordpress.ini, /etc/php/8.3/cli/conf.d/99-cloudimg-wordpress.ini
MariaDB settings /etc/mysql/mariadb.conf.d/99-cloudimg-wordpress.cnf
Scheduled jobs wordpress-cron.timer, wordpress-cron.service
Certificate and key /etc/nginx/ssl/
First boot and helpers /usr/local/sbin/wordpress-firstboot.sh, wordpress-set-hostname, wordpress-set-password, wordpress-cred-roundtrip
Licence, release and image notes /usr/share/doc/cloudimg-wordpress/
Credentials /root/wordpress-credentials.txt (mode 0600)

Step 15: Security recommendations

  • Allow port 22 in the security group from your own networks only.
  • Change the administrator e-mail to your own (Step 4), keep its password secret and rotate it if it may have been shared (Step 13). Give editors and authors their own accounts with the narrowest role that works.
  • Keep wp-config.php and your database backups secret (Step 8).
  • Use your own DNS name and a certificate your browsers trust (Step 12).
  • Leave automatic minor updates on, keep plugins and themes updated, and install only plugins and themes you trust. Keep the Classic editor off: re-enabling it brings back TinyMCE 4.9.11 and its unfixed advisories (Step 6).
  • 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

WordPress is free software under the GNU General Public License, version 2 or later (a copy is at /usr/share/doc/cloudimg-wordpress/LICENSE); the Twenty Twenty-Five theme is GPL-2.0-or-later, WP-CLI is MIT licensed, and WordPress's bundled libraries carry their own open source licences. There is no per user or per CPU licence fee and no licence key. This image installs the upstream release without the Akismet and Hello Dolly plugins and the two older default themes, plus the cloudimg first boot, settings, must-use plugins and helpers described above. WordPress is a trademark of the WordPress Foundation; cloudimg is not affiliated with or endorsed by it. 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