WordPress Multisite 6.9
Videos
Overview: WordPress Multisite 6.9 on Ubuntu 24.04 on Azure: overview (watch on YouTube)
Launch and log in: WordPress Multisite 6.9 on Ubuntu 24.04 on Azure: launch and log in (watch on YouTube)
Deep Dive: WordPress Multisite 6.9 on Ubuntu 24.04 on Azure: deep dive (watch on YouTube)
Overview
WordPress Multisite lets you run a network of sites from a single WordPress install. The sites share one codebase, plugins and themes, while each site keeps its own content, users and settings.
The SecureAnyCloud image installs WordPress 6.9.7 from wordpress.org (checksum-verified) alongside Apache 2.4, MySQL 8.0 and PHP 8.3. All packages come from Ubuntu noble, with no third-party APT repositories. The network is pre-configured in subdirectory layout, so you can add http://your-host/site2/, http://your-host/blog/ and similar sites with no wildcard DNS work.
The network answers at whatever public IP or DNS name you reach the VM on, and every site you add is served at that address too.
What you get:
- WordPress 6.9.7 with Network Multisite enabled at
/var/www/html/wordpress, and automatic 6.9.x security and maintenance releases (WP_AUTO_UPDATE_COREisminor). - Apache 2.4.x with mod_rewrite, plus the multisite RewriteRule block built into the vhost (no .htaccess dependency).
- MySQL 8.0.x with a
wordpressdatabase and awpuserMySQL user (per-VM password). - PHP 8.3.x with the mysqli, gd, mbstring, xml, curl and zip extensions. WordPress resizes images with PHP's GD library, and ImageMagick is not installed.
- WP-CLI 2.x at
/usr/local/bin/wp. wordpress-multisite-firstboot.service. On the first boot of your VM, it generates new MySQL root, WordPress database, WordPress admin and MySQL maintenance (debian-sys-maint) passwords, and all eight WordPress authentication keys and salts inwp-config.php. MySQL also generates its own TLS and RSA keys on that boot, so no two VMs share a password, a login-cookie key or a database key.- Apache and MySQL
enabledand auto-starting on boot. - 24/7 SecureAnyCloud support.
Prerequisites
Before you deploy this image, make sure you have an active Azure subscription, an SSH key, and a VNet with a subnet.
A Standard_B2s VM (4 GB RAM) is fine for small networks (under 20 sites, low traffic). Use D2s or D4s for medium networks or WooCommerce stores.
Step 1 to 3: Deploy and SSH (standard pattern)
Deploy the VM, then connect over SSH:
ssh azureuser@<vm-ip>Verify Services
Check that Apache and MySQL are running:
sudo systemctl status apache2 mysql --no-pager | head -20Read Per-VM Credentials
The first-boot service rotates passwords and writes the per-VM credentials to /var/lib/sac/secrets/sac-credentials.txt. Read the file as root:
sudo cat /var/lib/sac/secrets/sac-credentials.txtThe file looks like this:
# WordPress Multisite 6.9 — Per-VM Credentials
# Generated: <date>
#
# Visit your VM's public IP in a browser to access WordPress: # http://<your-public-ip>/ (front of site) # http://<your-public-ip>/wp-admin/ (single-site admin) # http://<your-public-ip>/wp-admin/network/ (network superadmin) # WP_ADMIN_USER=admin WP_ADMIN_PASSWORD=<random> WP_DB_NAME=wordpress WP_DB_USER=wpuser WP_DB_PASSWORD=<random> MYSQL_ROOT_PASSWORD=<random>Login to wp-admin
Go to http://<vm-ip>/wp-login.php and sign in as admin with the password from Step 5. To confirm the WordPress version from the command line, run:
sudo -u www-data /usr/local/bin/wp core version --path=/var/www/html/wordpressNetwork Admin: Sites
After you sign in, go to http://<vm-ip>/wp-admin/network/sites.php to see your network's sites. The primary site (blog_id=1) is created automatically.
Add a New Site
On the Network Admin Sites page, select Add New to create another site in the network.
In subdirectory layout, new sites live under paths such as http://<vm-ip>/blog/ or http://<vm-ip>/store/ with no DNS work. Each site's own dashboard is at http://<vm-ip>/<site-slug>/wp-admin/.
WP-CLI Network Operations
WP-CLI is fully network-aware. List the sites in your network:
sudo -u www-data /usr/local/bin/wp site list --path=/var/www/html/wordpressCreate a new site without the web interface:
sudo -u www-data /usr/local/bin/wp site create --slug=blog --title="Blog" --path=/var/www/html/wordpressList the network plugins:
sudo -u www-data /usr/local/bin/wp plugin list --network --path=/var/www/html/wordpressComponents
These are the main components and where they live on the VM:
- WordPress install: /var/www/html/wordpress/
- wp-config.php (with multisite block): /var/www/html/wordpress/wp-config.php
- Apache vhost (with multisite RewriteRules): /etc/apache2/sites-available/wordpress.conf
- Apache logs: /var/log/apache2/wordpress-{access,error}.log
- MySQL data: /var/lib/mysql/
- WP-CLI: /usr/local/bin/wp
- First-boot script: /usr/local/sbin/wordpress-multisite-firstboot.sh
- Credentials: /var/lib/sac/secrets/sac-credentials.txt (mode 0600 root:root)
Security
- HTTPS: install certbot and obtain a Let's Encrypt certificate with the
--apacheplugin:sudo apt-get install -y certbot python3-certbot-apache && sudo certbot --apache. If you ever switch to subdomain layout, you will want a wildcard certificate. Otherwise, the certbot Apache plugin handles the primary host fine. - Restrict the network security group (NSG). Open port 80 only to your CDN, load balancer or Cloudflare, and keep port 22 open to your management CIDR only.
- WordPress core updates: security and maintenance releases on the 6.9 branch install automatically (
define( 'WP_AUTO_UPDATE_CORE', 'minor' );inwp-config.php). Moving to a new major release is your decision. Back up first, then runsudo -u www-data /usr/local/bin/wp core update --path=/var/www/html/wordpress - Update plugins and themes monthly at the network level:
wp plugin update --all --networkandwp theme update --all --network - Enable two-factor authentication network-wide with Wordfence or the Two Factor Authentication plugin, and network-activate the plugin.
- Patch monthly:
apt-get update && apt-get upgrade && reboot - Limit super-admins. Only the
adminuser should be a super-admin. Per-site admins should be regular admins.
Multisite Notes
- Subdirectory or subdomain layout: this image is configured in subdirectory layout (sites at
/site2/,/blog/and so on). To switch to subdomain layout, editwp-config.phpand changedefine('SUBDOMAIN_INSTALL', false);totrue. Then add a wildcard A record*.your-domain.com -> <vm-ip>, and re-runwp core multisite-convert --subdomainsif you have not yet added new sites. - Network Admin URL:
http://<vm-ip>/wp-admin/network/(not/wp-admin/) - Adding sites in the web interface: the per-site admin URL is
http://<vm-ip>/<site-slug>/wp-admin/
Licensing
WordPress is GPL-2.0+ and free to use commercially. SecureAnyCloud provides commercial support separately. sales@secureanycloud.com.
Deploy on Azure
Launch WordPress Multisite 6.9 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
Overview
This guide covers launching and using the WordPress Multisite 6.9 on Ubuntu 24.04 AMI from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud BookStack, CMS, Blogs and Wikis listing on AWS Marketplace. You choose it as the software version when you subscribe and launch.
WordPress Multisite runs a network of WordPress sites from one installation. The sites share WordPress itself, the plugins and the themes, while each site keeps its own posts, pages, media, settings and users.
This image runs WordPress 6.9.11 (the current 6.9 security release) as a subdirectory network, so each new site lives at https://<your host>/<site>/ with no wildcard DNS needed. It uses the Twenty Twenty-Five theme, served by nginx and PHP-FPM 8.3 from a local MariaDB 10.11 database.
What you get:
- A network that is yours on first boot. The image ships no database (the MariaDB data directory is empty), no
wp-config.phpand no reachable installer. Your instance's first boot creates the database and runs WordPress's own network installer through WP-CLI, so the network and its administrator exist 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.
- One command that moves the whole network to your DNS name.
wordpress-set-hostnamemoves the network and every site in it, and mints a certificate for the new name (Step 13). - HTTPS from the first boot. nginx serves the network on port 443 with a certificate generated on your instance's first boot. Port 80 redirects to HTTPS.
- Fail-closed start. nginx does not start until first boot has finished. If first boot cannot complete, the network stays offline rather than open.
- The Classic editor's TinyMCE is off by default, on every site. 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 on the main site and on every site you add. A must-use plugin turns off the Classic (visual) editor and the Classic block, and nginx refuses every TinyMCE file under every site's address. The block editor is unaffected (Step 7).
- The same safe defaults for every new site. A must-use plugin gives each site you add the same settings: no Gravatar requests, pingbacks and trackbacks off, UTC and
/%postname%/permalinks. Sign up (for users and for sites) is off. - Automatic minor security updates. WordPress applies 6.9.x security releases by itself. The scheduled jobs of every site in the network run from a system timer every 5 minutes instead of on visitors' page loads (Step 11).
- 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. Avatars (Gravatar) and the emoji script are off, so pages load nothing from third-party servers. - Uploads never run. Files in
wp-content/uploadsare served as static files only, each withnosniffand a sandboxing content security policy. Anything but images, PDF, fonts, audio and video is sent as a download. PHP runs only from WordPress itself and fromwp-content/pluginsandwp-content/themes. - Requests from other sites cannot act for you. Every request that changes something (every form submission and REST write, on any site of the network) works only when the request comes from your network itself. The same applies to the admin-ajax, admin-post and oEmbed proxy endpoints of every site (Step 10).
- A rate-limited sign-in. nginx allows 10 sign-in attempts a minute from one address. It uses the address of the connection, never a header that 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,/tmpor the session folder can be executed. The web server's PHP is confined to the site withopen_basedir. - WP-CLI as the web server user.
sudo wp ...runs WP-CLI aswww-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
rootaccount 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
Before you deploy this image, make sure you have:
- 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.orgfor 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 port 22 open to your own networks only. Open port 443 to your visitors for public sites, or only to your team while you build them.
Launch the instance
In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Pick WordPress Multisite 6.9 on Ubuntu 24.04 as the software version and your Region, then choose Launch through EC2. Choose m5.large, your key pair and a security group as above.
To launch with the AWS CLI instead, run the following command and 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-multisite}]'Connect to your instance
Connect over SSH as the login user for your version:
- WordPress Multisite 6.9 on Ubuntu 24.04: SSH user is ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>Check the services
First boot takes under a minute while it creates the database and runs WordPress's network installer. After that, nginx, PHP, MariaDB, the first-boot unit and the timer for the network's scheduled jobs are active. Check them:
systemctl is-active nginx php8.3-fpm mariadb wordpress-firstboot wordpress-cron.timeractive active active active activenginx serves ports 80 and 443. PHP and MariaDB listen on local sockets only, so the only open ports are 22, 80 and 443. Check the listeners:
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"))Show 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/sac-wordpress/RELEASEWordPress 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/sac/wordpress/mu-plugins/sac-wp-no-tinymce.php + /etc/nginx/snippets/sac-wp-no-tinymce.confThe network starts with one site (the main site) and one network administrator. A network administrator, also called a super admin, manages every site. List them:
sudo wp site list --fields=blog_id,url sudo wp super-admin listblog_id url 1 https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/ sacGet your sign-in details
The credentials file holds the address the network is served as (your instance's public DNS name by default), the administration and Network Admin addresses, the administrator's user name, email and password, and the database credentials. All of these are generated on this instance. Show the file:
sudo cat /root/wordpress-credentials.txtWORDPRESS_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_NETWORK_ADMIN_URL=https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/wp-admin/network/ WORDPRESS_ADMIN_USER=sac 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 email admin@example.com and the display name Site Administrator. Change both to your own under Users > Profile. Also change the network's administrator email under Network Admin > Settings. Do both before you rely on WordPress's password emails.
The administrator's public author name (site-admin) is not the sign-in name.
Visit the network 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 13). The main site shows the Twenty Twenty-Five theme with WordPress's Hello world! post.
Open WORDPRESS_ADMIN_URL and sign in with WORDPRESS_ADMIN_USER and WORDPRESS_ADMIN_PASSWORD.
The main site's dashboard opens. My Sites in the toolbar lists every site of the network and leads to Network Admin, where you manage sites, users, themes, plugins and the network's settings.
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 11).
Add sites to the network
Open WORDPRESS_NETWORK_ADMIN_URL (My Sites > Network Admin). The Sites page lists the network's sites.
Sites > Add Site creates a site at https://<your host>/<site address>/ with its own title, language and administrator. To make an existing user the site's administrator, enter that user's email. A new email creates a new user, who needs your SMTP set up (Step 14) to receive the sign-in link.
You can do the same from the command line. A new site gets SecureAnyCloud's defaults from the must-use plugin sac-wordpress-multisite (/%postname%/ permalinks, no avatars, pings closed, UTC). WordPress tries to email you about the new site, and with no mail server the sendmail: not found line is expected:
H=$(sed -n 's/^set \$wp_host "\(.*\)";$/\1/p' /etc/nginx/wordpress-host.conf)
sudo wp site create --slug=team --title='Team site' for o in permalink_structure show_avatars default_ping_status timezone_string; do printf '%s=%s\n' "$o" "$(sudo wp --url="https://$H/team/" option get $o)"; done curl -sk -o /dev/null -w "/team/: %{http_code}\n" --resolve "$H:443:127.0.0.1" "https://$H/team/"sh: 1: /usr/sbin/sendmail: not found Success: Site 2 created: https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/team/ permalink_structure=/%postname%/ show_avatars=0 default_ping_status=closed timezone_string=UTC /team/: 200Run WP-CLI against one site with --url, for example sudo wp --url=https://<your host>/team/ post list. Without it, WP-CLI works on the main site.
Remove a site with Sites > Delete or sudo wp site delete --slug=<site> --yes. This deletes the site's posts, pages and media.
Site addresses are subdirectories only, so they must not clash with the main site's pages. For that reason, the main site's posts live under /blog/ (Step 8).
Sign up is off. Visitors cannot register users or sites (registration is none), and site administrators cannot add users (add_new_users is 0). Change either under Network Admin > Settings if you want them. wp-signup.php then becomes your sign-up page.
Write with the block editor (the Classic editor is off)
On every site, posts and pages open in the block editor. Add paragraphs, headings, images, galleries, columns, buttons and patterns from the + inserter.
Appearance > Editor opens the Site Editor, where each site designs its theme's templates, header, footer, colours and fonts with the same blocks.
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 on any site could store content that runs script in an editor's browser when the Classic editor opens it, and on a network that editor may be a super admin.
So this image keeps TinyMCE unreachable by default on every site, in two independent layers:
- The must-use plugin
sac-wp-no-tinymce(in/usr/local/lib/sac/wordpress/mu-plugins, owned by root, outside the web root, with no deactivate switch in Network Admin or any site's admin area, and no option a site can change) turns the visual editor off for every user on every site. It also 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 under every site's address, 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 that 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 /any-site/wp-includes/js/tinymce/tinymce.min.js /WP-INCLUDES/JS/TINYMCE/tinymce.min.js "/wp-admin/load-scripts.php?load%5Bchunk_0%5D=wp-tinymce" "/any-site/wp-admin/load-scripts.php?load%5Bchunk_0%5D=wp-tinymce" /wp-includes/js/jquery/jquery.min.js /any-site/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" donename status sac-wp-no-tinymce must-use sac-wordpress-hardening must-use sac-wordpress-multisite must-use name status version twentytwentyfive active 1.5 /wp-includes/js/tinymce/tinymce.min.js: 403 /any-site/wp-includes/js/tinymce/tinymce.min.js: 403 /WP-INCLUDES/JS/TINYMCE/tinymce.min.js: 403 /wp-admin/load-scripts.php?load%5Bchunk_0%5D=wp-tinymce: 403 /any-site/wp-admin/load-scripts.php?load%5Bchunk_0%5D=wp-tinymce: 403 /wp-includes/js/jquery/jquery.min.js: 200 /any-site/wp-includes/js/jquery/jquery.min.js: 200(403 is nginx refusing the file. jQuery, which the admin area needs, still loads, for the main site and under any site's address.)
What re-opens the advisories. 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, on every site of the network.
The Classic Editor plugin uses core's TinyMCE (off while user_can_richedit is false), so network-activating it together with removing the mu-plugin brings TinyMCE back. So does removing sac-wp-no-tinymce.php or the nginx rule /etc/nginx/snippets/sac-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).
SecureAnyCloud 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 on any site, and check the release notes of each WordPress upgrade (Step 11).
The closed-by-default settings
These are the image defaults and where to change each one:
- Main site permalinks: /blog/%postname%/. Change them in the main site's Settings > Permalinks.
- New sites' permalinks: /%postname%/. Change them in each site's Settings > Permalinks.
- Sign up (users and sites): off. Change it in Network Admin > Settings.
- Site administrators can add users: off. Change it in Network Admin > Settings.
- Pingbacks and trackbacks: off. Change them in each site's Settings > Discussion.
- Avatars (Gravatar): off. Change them in each site's Settings > Discussion.
- Time zone: UTC. Change it in each site's Settings > General.
- XML-RPC and application passwords: off. They are controlled by the must-use plugin sac-wordpress-hardening.php.
- Theme and plugin file editor: off (DISALLOW_FILE_EDIT). Change it in wp-config.php.
- Automatic core updates: minor (6.9.x) releases. Change them in wp-config.php (WP_AUTO_UPDATE_CORE).
sudo wp option get permalink_structure for o in default_ping_status default_pingback_flag show_avatars timezone_string blog_public; do printf '%s=%s\n' "$o" "$(sudo wp option get $o)"; done for o in registration add_new_users; do printf '%s=%s\n' "$o" "$(sudo wp network meta get 1 $o)"; done
sudo grep -E "^define\( '(MULTISITE|SUBDOMAIN_INSTALL|PATH_CURRENT_SITE|WP_AUTO_UPDATE_CORE|DISALLOW_FILE_EDIT|DISABLE_WP_CRON|FS_METHOD|FORCE_SSL_ADMIN|WPMU_PLUGIN_DIR)'" /var/www/wp-config.php/blog/%postname%/ default_ping_status=closed default_pingback_flag=0 show_avatars=0 timezone_string=UTC blog_public=1 registration=none add_new_users=0 define( 'MULTISITE', true ); define( 'SUBDOMAIN_INSTALL', false ); define( 'PATH_CURRENT_SITE', '/' ); 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/sac/wordpress/mu-plugins' );blog_public=1 means search engines may index the site. Set each site's Settings > Reading > Search engine visibility while you build it.
wp-config.php sets no WP_HOME or WP_SITEURL. On a network they would override every site's address, so each site's address lives in the database.
Your data
Everything that makes your network lives in two places. The wordpress database in MariaDB holds one set of tables per site plus the network's tables. The uploads are under /var/www/wordpress/wp-content/uploads, with each added site's media under uploads/sites/<id>/. Plugins and themes you add are under wp-content/plugins and wp-content/themes.
Keep /var/www/wp-config.php with your backup, because it holds this network's database password, keys and salts.
Back up the database to a file that only root can read. MariaDB's root account 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-contentFor a whole-instance copy, take a snapshot of the EBS volume.
Security checks
Port 80 sends browsers to HTTPS, and the health endpoint answers for load balancer checks. The following are not reachable: XML-RPC, the web cron trigger, the configuration, the installer, the readme, WordPress's PHP include files, a path after index.php (on the main site or under any site's address), PHP in uploads, and dotfiles. Verify this:
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 /any-site/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 /any-site/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/ || trueport 80: 301 redirects to: https://<your host>/wp-admin/ ok /xmlrpc.php: 403 /any-site/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 /any-site/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. Check both:
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: 403Only root can read the credentials file, and only root and the web server can read wp-config.php. The site files belong to the web server user so that WordPress can update itself, while the must-use plugins belong to root. Check the permissions:
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/sac/wordpress/mu-plugins /usr/local/lib/sac/wordpress/mu-plugins/sac-wp-no-tinymce.php /usr/local/lib/sac/wordpress/mu-plugins/sac-wordpress-multisite.php600 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/sac/wordpress/mu-plugins 644 root:root /usr/local/lib/sac/wordpress/mu-plugins/sac-wp-no-tinymce.php 644 root:root /usr/local/lib/sac/wordpress/mu-plugins/sac-wordpress-multisite.phpAn uploaded HTML file is sent as a download with nosniff and the sandboxing policy. It is never rendered as a page of your network. Test this with a demo file:
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-demoHTTP/2 200 x-content-type-options: nosniff content-security-policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox content-disposition: attachmentWordPress and PHP are blocked from the EC2 instance metadata service, in the web stack and in WP-CLI. A vulnerable plugin on any site therefore cannot fetch the instance's credentials. Check the block:
for s in nginx php8.3-fpm mariadb wordpress-cron; do printf '%s: %s\n' "$s" "$(systemctl show -p IPAddressDeny --value $s.service | tr ' ' '\n' | sort | xargs)"; 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: unreachableUpdates and scheduled jobs
Automatic updates. WordPress installs minor (6.9.x) releases by itself, normally within 12 hours of their release, because the site files belong to the web server user and WP_AUTO_UPDATE_CORE is minor. One update covers every site of the network.
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 Network Admin > 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. The scheduled jobs of every site in the network (update checks, publishing scheduled posts and cleanups) run every 5 minutes from wordpress-cron.timer as the web server user. cron-all-sites.sh runs each site's due events in turn. The jobs do not run on visitors' page loads, and wp-cron.php is not reachable. Show the main site's jobs:
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 | sortenabled Success: WordPress is at the latest version. delete_expired_transients,"1 day" do_pings,Non-repeating update_network_counts,"12 hours" 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_plugins,"12 hours" wp_update_themes,"12 hours" wp_version_check,"12 hours"Major upgrades. A new major release (such as 7.x) is not installed automatically. Take a snapshot and check that your plugins and themes support the release. Then use Network Admin > Updates, or run sudo wp core update followed by sudo wp core update-db --network. After any upgrade, check that the Classic editor is still off (Step 7).
Plugins and themes. Only the network administrator can install plugins and themes. A plugin you network-activate runs on every site with the network's rights, so install only what you trust and keep it updated. The admin area cannot edit plugin or theme files (DISALLOW_FILE_EDIT).
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. WordPress therefore sees the address of the connection and never builds links from a host that 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. Terminate HTTPS on port 443 of the instance, because WordPress forces HTTPS for the admin area.
Your own DNS name and certificate
Point a DNS record at the instance, then set the name that the network is served as. This moves the whole network to the new name. The command does all of the following:
- Updates
DOMAIN_CURRENT_SITEinwp-config.php. - Rewrites the network's address and the address of every site 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. - Keeps the name across reboots.
sudo wordpress-set-hostname <your-domain>Links inside posts and pages keep the address they were written with. If you wrote content under the old name, rewrite those links once on every site with sudo wp search-replace 'https://<old-host>' 'https://<your-domain>' --network --skip-columns=guid. Take a backup first (Step 9).
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 your certificate 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 nginxOnce browsers trust the certificate, you can also add an HSTS header (add_header Strict-Transport-Security "max-age=31536000" always;) to /etc/nginx/snippets/sac-wordpress-headers.conf. The image does not send one, because browsers ignore HSTS from a site whose certificate they do not trust.
Passwords, email and WP-CLI
Run wordpress-set-password to give the network administrator a new random password. The command signs out every session and records the password in /root/wordpress-credentials.txt. Users change their own password under Users > Profile.
sudo wordpress-set-passwordEmail. The image has no mail server, so WordPress cannot send password reset, new site, new user, comment or update emails. To enable email, add an SMTP plugin you trust (network-activated and 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 network. Add --url=https://<your host>/<site>/ to work on one site. Examples are sudo wp site list, sudo wp user list --network and sudo wp core verify-checksums.
Server components
These are the server components and where they live:
- WordPress (owned by www-data, updated by WordPress): /var/www/wordpress
- Configuration (this instance's database password, keys and salts, and the network settings): /var/www/wp-config.php
- Uploads (added sites under sites/<id>/): /var/www/wordpress/wp-content/uploads
- SecureAnyCloud must-use plugins (owned by root): /usr/local/lib/sac/wordpress/mu-plugins/
- WP-CLI and its sandboxed wrapper: /usr/local/lib/sac/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/sac-wordpress, /etc/nginx/conf.d/sac-wordpress-guard.conf, /etc/nginx/conf.d/sac-xsite-guard.conf, /etc/nginx/snippets/sac-wordpress-*.conf, /etc/nginx/snippets/sac-wp-no-tinymce.conf
- PHP settings: /etc/php/8.3/fpm/conf.d/99-sac-wordpress.ini, /etc/php/8.3/cli/conf.d/99-sac-wordpress.ini
- MariaDB settings: /etc/mysql/mariadb.conf.d/99-sac-wordpress.cnf
- Scheduled jobs (every site): wordpress-cron.timer, wordpress-cron.service, /usr/local/lib/sac/wordpress/cron-all-sites.sh
- 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/sac-wordpress/
- Credentials: /root/wordpress-credentials.txt (mode 0600)
Security recommendations
- Allow port 22 in the security group from your own networks only.
- Change the administrator email to your own (Step 4), keep its password secret, and rotate it if it may have been shared (Step 14). Keep super admin rights to the people who run the network. Give each site's editors and authors their own accounts, with the narrowest role that works, on that site only.
- Keep sign up off unless you need it. If you open it, add spam protection you trust (Step 6).
- Keep
wp-config.phpand your database backups secret (Step 9). - Use your own DNS name and a certificate that your browsers trust (Step 13).
- Leave automatic minor updates on, keep plugins and themes updated, and install only plugins and themes you trust. Keep the Classic editor off, because re-enabling it brings back TinyMCE 4.9.11 and its unfixed advisories on every site (Step 7).
- Keep the operating system patched. Unattended security upgrades are on, and
sudo apt-get update && sudo apt-get upgradeapplies everything else. MariaDB, PHP-FPM and PHP's zip extension come from Ubuntu's universe repository. Runsudo pro security-status --esm-appsto see 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/sac-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, per-site 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. It adds the SecureAnyCloud first boot, settings, must-use plugins and helpers described above. WordPress is a trademark of the WordPress Foundation, and SecureAnyCloud is not affiliated with or endorsed by it. The AMI price covers SecureAnyCloud's hardening, first-boot setup and support.
- Email: sales@secureanycloud.com
- Website: https://secureanycloud.com
- Support: 24/7, with a 24-hour response SLA
Deploy on AWS
Launch WordPress Multisite 6.9 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com