Configuring a Secure WordPress LEMP Stack with chroot Jails

Host · Ubuntu 24.04 LTSone kernel · one nginx · one mariadbClientshttps · http/3nginxtls 1.3 · limit_req/var/www/site1chroot · own userphp-fpm · site1OK/var/www/site2chroot · own userphp-fpm · site2CONTAINEDplugin exploit/var/www/site3chroot · own userphp-fpm · site3OKMariaDBone db per siteplugin exploitjail wall holdssite unaffected
One host, three jails. A plugin exploit in site 2 stays in site 2.

WordPress is as widespread as it is under-served by hosting guides that are both current and serious about isolation. This one builds a multi-site host on which a compromise of one site stays in that site — one chroot jail per site, a dedicated PHP-FPM pool inside each jail, nginx with HTTP/3 in front, MariaDB behind — and it is written against the versions in section 1, so you can check its age before you trust it.

1. Versions this guide is tested against #

Everything below is written against a handful of variables rather than hard-coded numbers: the versions live in this table and in one block of shell variables, so what the guide assumes is visible at a glance.

ComponentThis guideNotes
Ubuntu24.04 LTS (Noble Numbat)Standard support to May 2029. 26.04 LTS (Resolute Raccoon, released 23 April 2026, supported to May 2031) runs the same commands; where the two differ, the text says so. 22.04 leaves standard support in June 2027 — do not build new on it.
PHP8.5Active support to 31 December 2027, security fixes to 31 December 2029; fully supported by WordPress 7.x. 26.04 ships it; 24.04 takes it from a PPA (section 5). 8.4 is the conservative alternative (security fixes to December 2028). Do not build new on 8.2 (security fixes end 31 December 2026) or 8.1 (end-of-life).
MariaDB10.11 LTS on 24.04 · 11.8 LTS on 26.04From Ubuntu’s own archive. WordPress’s stated requirement is now MariaDB 10.11+ or MySQL 8.0+.
nginx1.30.x (stable)From nginx.org, not the distro package: HTTP/3 needs 1.25 or later and 24.04 ships 1.24.
Certbotsnap (classic)The apt package is no longer the recommended install.
WordPresscurrent stable (7.1 at the time of writing)Minor releases update themselves; majors are done deliberately.

Set these once, in every shell you use for this guide (or put them in a file and source it). The commands below expand them in your shell before sudo ever sees them:

bash
export UBUNTU_CODENAME=noble        # noble = 24.04 · resolute = 26.04
export PHP_VERSION=8.5
export SITE=site1                   # short name: user, group, pool, jail, socket, database
export DOMAIN=site1.example.com
export JAIL=/var/www/${SITE}

Configuration files cannot read shell variables, so the pool file and the nginx server blocks below carry three literals — site1, site1.example.com and 8.5 — and say so in their first line. Change those three and nothing else.

2. Why a jail per site #

The threat being designed against is not “someone breaks into the server”. It is the far more common “one WordPress plugin has a file-upload vulnerability”. Without isolation, that single plugin gives an attacker read access to every other site on the box — including every wp-config.php, and therefore every database.

Each site therefore gets:

  • its own Unix user and group
  • its own chroot jail, which is also that user’s home directory
  • its own PHP-FPM pool, running as that user, chrooted into the jail
  • its own database and database user

A compromise of one site sees a filesystem containing that site and almost nothing else.

3. Base system #

Start from a non-root administrator with sudo and key-based SSH — DigitalOcean’s initial server setup for 24.04 still covers that well. Then:

bash
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y curl gnupg ca-certificates software-properties-common unattended-upgrades ufw
sudo dpkg-reconfigure --priority=low unattended-upgrades

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp          # HTTP/3 is QUIC, and QUIC is UDP
sudo ufw enable

unattended-upgrades is installed and enabled for security updates on a stock 24.04; the reconfigure step lets you confirm that rather than assume it. Security patches applying themselves are worth more than the small risk of an unattended restart, and the alternative — as we have seen on real fleets — is boxes quietly sitting years behind. What unattended-upgrades cannot do is move you between major versions: set a calendar reminder each January to check the end-of-support dates for PHP, MariaDB, nginx’s stable branch and your Ubuntu LTS — every one of them is published years in advance. software-properties-common provides add-apt-repository, which section 5 needs.

4. MariaDB #

Why MariaDB rather than MySQL: MySQL 8.0 reached upstream end-of-life on 30 April 2026, and Ubuntu’s two current LTS releases now carry different MySQL majors — 8.0, Canonical-patched, on 24.04 and 8.4 on 26.04 — which differ in ways that bite (8.4 switches off mysql_native_password by default). MariaDB tells one story on both: an LTS from Ubuntu’s own archive, with published end-of-support dates. WordPress supports either.

UbuntuMariaDB in the Ubuntu archiveUpstream supported until
24.04 LTS10.11 LTS (universe; updates and security fixes have flowed since release)February 2028
26.04 LTS11.8 LTS (promoted to main)June 2028

WordPress’s requirements page now asks for MariaDB 10.11 or newer, so 24.04’s package meets the floor exactly and 26.04’s clears it comfortably.

bash
sudo apt install -y mariadb-server
sudo mariadb-secure-installation

That is the entire install. On Ubuntu the root database account authenticates through the Unix socket, so sudo mariadb gets you in without a password. If you specifically need 11.8 on 24.04, MariaDB’s own APT repository is the supported route — but for a WordPress host, the distro package is the lower-maintenance choice.

Per-site database and user. The grant is scoped to one schema, and the user is created for 127.0.0.1, not localhost — inside the jail there is no /run/mysqld/mysqld.sock, so PHP will connect over TCP (section 11 comes back to this):

bash
DBPASS="$(openssl rand -base64 24)" && echo "${SITE} database password: ${DBPASS}"
sudo mariadb <<SQL
CREATE DATABASE ${SITE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
CREATE USER '${SITE}'@'127.0.0.1' IDENTIFIED BY '${DBPASS}';
GRANT ALL PRIVILEGES ON ${SITE}.* TO '${SITE}'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL

utf8mb4_unicode_520_ci is the collation WordPress itself picks when a server offers it, so the database default and the tables agree from day one.

Note

MariaDB 10.5 and later ship mariadb-prefixed tools (mariadb, mariadb-dump, mariadb-secure-installation). The old mysql* names remain as compatibility symlinks, so existing scripts keep working — but prefer the new names in anything you write today.

5. PHP #

Ubuntu 26.04 ships PHP 8.5 in its own archive. 24.04 ships 8.3, so on 24.04 take 8.5 from the widely trusted ondrej/php PPA — one extra line, and every package name below is the same on both releases. WordPress 7.x supports 8.5 outright; if a plugin you depend on has not caught up, 8.4 is a sound choice and the variable in section 1 makes it a one-line change.

bash
# 24.04 only — 26.04 has 8.5 in its own archive, so skip this line there
sudo add-apt-repository -y ppa:ondrej/php && sudo apt update

sudo apt install -y \
  php${PHP_VERSION}-fpm php${PHP_VERSION}-cli php${PHP_VERSION}-mysql php${PHP_VERSION}-curl \
  php${PHP_VERSION}-gd php${PHP_VERSION}-mbstring php${PHP_VERSION}-xml php${PHP_VERSION}-zip \
  php${PHP_VERSION}-intl php${PHP_VERSION}-imagick php${PHP_VERSION}-bcmath

Disable the default pool so nothing ever runs un-jailed. FPM refuses to start with no pools at all, so the restart waits until section 9, once the first jailed pool exists:

bash
sudo mv /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf \
        /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf.disabled

6. The chroot jails #

6.1 Create the user and jail #

The jail is the user’s home directory, but the jail’s root stays owned by root: the site user owns what it needs to write — the document root and a tmp — and nothing else. --no-create-home keeps adduser from copying shell dotfiles into a directory nothing will ever log in to.

bash
sudo adduser --comment "${SITE} web" --disabled-password --shell /usr/sbin/nologin \
             --home ${JAIL} --no-create-home ${SITE}
sudo mkdir -p ${JAIL}/public ${JAIL}/tmp
sudo chown ${SITE}:${SITE} ${JAIL}/public ${JAIL}/tmp
sudo chmod 1777 ${JAIL}/tmp

Inside the jail the document root is /public, because from PHP’s point of view the jail is /. The tmp matters: it is where PHP puts uploads and sessions once it can no longer see the real /tmp.

6.2 Bind the handful of system paths PHP needs #

A chrooted PHP still needs a few files: timezone data, /dev/urandom, DNS resolution and the CA bundle for outbound HTTPS. They are bind-mounted read-only into each jail by a small helper, php-chroot-bind, that has not needed a commit since 2018 — so pin the revision and check the hash rather than trusting master:

bash
REV=39c7fa6454473780cacfd883bedc6d5c215c25da
sudo curl -fsSL -o /usr/local/sbin/php-chroot-bind \
  https://raw.githubusercontent.com/68b32/php-chroot-bind/${REV}/php-chroot-bind
echo "68893938e8e252fd35b730d50ceeb1d45fb9182280aa4b3ad82ffe64ea90c08f  /usr/local/sbin/php-chroot-bind" | sha256sum -c
sudo chmod 755 /usr/local/sbin/php-chroot-bind

Two one-line edits make it ours. The script finds jails by listing the author’s /home/www/*/chroot layout; ours are registered in a list. And its systemd generator hard-codes php7.0-fpm.service, so the units it writes would order themselves against a service that does not exist — which is exactly why so many installs fall back to an @reboot cron entry.

bash
sudo sed -i 's#^_CHROOTS_CMD=.*#_CHROOTS_CMD="cat /var/www/chroot.list"#' /usr/local/sbin/php-chroot-bind
sudo sed -i "s/php7\.0-fpm\.service/php${PHP_VERSION}-fpm.service/g"      /usr/local/sbin/php-chroot-bind

Register the jail, bind, and generate the boot-time units:

bash
echo "${JAIL}" | sudo tee -a /var/www/chroot.list
sudo php-chroot-bind bind
sudo php-chroot-bind status                  # every path shows a green +
sudo php-chroot-bind systemd update          # writes the .mount units and a php-chroots.target
sudo systemctl enable php-chroots.target     # the target is wanted by php8.5-fpm.service and ordered before it

The set it binds into every jail:

text
/usr/share/zoneinfo
/dev/urandom  /dev/zero  /dev/null
/etc/resolv.conf
/lib/x86_64-linux-gnu/libnss_dns.so.2
/usr/share/ca-certificates
/etc/ssl/certs

Extras go in /var/www/bind.conf, one absolute path per line — the script reads ../bind.conf relative to each jail, so with jails directly under /var/www that one file applies to all of them. Two candidates: WordPress uses ImageMagick through the imagick extension only if the library can load its coder modules, which live outside the jail (/usr/lib/x86_64-linux-gnu/ImageMagick-6.9.12 on 24.04 — check ls -d /usr/lib/x86_64-linux-gnu/ImageMagick-*); without them WordPress silently falls back to GD, which is fine for most sites. And nothing can bind a mail transport into a jail: PHP’s mail() has no sendmail to call, so WordPress mail needs an SMTP plugin talking to a real provider — which you should be doing for deliverability regardless.

7. nginx (with HTTP/3) #

24.04’s distro package is 1.24, which predates HTTP/3. Use nginx.org’s stable repository (1.30 at the time of writing; the mainline branch is 1.31), and pin it so a distro upgrade can never swap it out:

bash
curl -fsSL https://nginx.org/keys/nginx_signing.key \
  | gpg --dearmor | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/ubuntu ${UBUNTU_CODENAME} nginx" \
  | sudo tee /etc/apt/sources.list.d/nginx.list
printf 'Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n' \
  | sudo tee /etc/apt/preferences.d/99nginx
sudo apt update && sudo apt install -y nginx
sudo systemctl enable --now nginx

nginx.org’s packages run their workers as the user nginx, not Ubuntu’s www-data — the socket permissions in section 9 depend on that. Now replace the package’s welcome page with a catch-all that answers nothing for hostnames you do not serve — not even a TLS handshake. Without it, nginx’s first server block answers for every stray hostname and for the bare IP, and your site turns up in scans it was never meant to be in:

/etc/nginx/conf.d/00-default.conf
# Anything that is not one of our hostnames gets nothing — not even a TLS handshake
server {
    listen 80  default_server;
    listen [::]:80  default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    ssl_reject_handshake on;
    return 444;
}
bash
sudo rm /etc/nginx/conf.d/default.conf

Then the site’s port-80 block. It is permanent: it serves the certificate challenges and redirects everything else to HTTPS, and it never changes again:

/etc/nginx/conf.d/site1-http.conf
# literals to change: site1.example.com, /var/www/site1
server {
    listen 80;
    listen [::]:80;
    server_name site1.example.com;

    location ^~ /.well-known/acme-challenge/ { root /var/www/site1/public; }
    location / { return 301 https://$host$request_uri; }
}
bash
sudo nginx -t && sudo systemctl reload nginx

8. TLS with Certbot #

Certbot’s nginx plugin will happily rewrite server blocks for you. This guide keeps the configuration under your control: Certbot only proves ownership through the webroot and writes the certificate; the server block in section 10 references it explicitly.

bash
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbot
sudo certbot certonly --webroot -w ${JAIL}/public -d ${DOMAIN} \
     --agree-tos -m webmaster@${DOMAIN} --no-eff-email

Renewal installs itself as a systemd timer, but a renewed certificate only takes effect when nginx reloads — with certonly, that is a deploy hook you add yourself. Then verify rather than assume:

bash
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
printf '#!/bin/sh\nsystemctl reload nginx\n' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl list-timers | grep certbot
sudo certbot renew --dry-run

Let’s Encrypt stopped sending expiry emails in 2025, so the dry run and the timer are your only assurances — check them after any change to the site’s DNS or the port-80 block.

9. The PHP-FPM pool #

/etc/php/8.5/fpm/pool.d/site1.conf
; literals to change: [site1], php8.5 in the socket path
[site1]
user  = $pool
group = $pool

listen       = /run/php/php8.5-fpm-$pool.sock
; nginx.org packages run workers as "nginx"; Ubuntu's own nginx package would be www-data
listen.owner = nginx
listen.group = nginx
listen.mode  = 0660

chroot = /var/www/$pool
chdir  = /public

; Neuter the functions used by nearly every PHP web shell
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,parse_ini_file,show_source
php_admin_flag[allow_url_fopen]    = off

; What the jail takes away, give back deliberately
php_admin_value[session.save_path]   = /tmp
; matches client_max_body_size in nginx (section 10); PHP's own default is 2M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size]       = 64M

; Sizing — read section 9.1 before changing these
pm                   = dynamic
pm.max_children      = 5
pm.start_servers     = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests      = 500

Using $pool for the user, group, socket and jail means this file is identical for every site apart from the section header and the sizing. Note that the socket path is outside the jail — the FPM master creates it before the workers chroot, which is why the pool listens at /run/php/... while its own /tmp is the jail’s.

The disable_functions list is eight entries, not the nine you will see on many hardening lists: curl_multi_exec is a legitimate HTTP-client call that plugins use for parallel requests, not a shell primitive, and disabling it produces fatal errors in the wrong place. If a plugin needs proc_open (rare — usually a build tool leaking into production), it is telling you something.

bash
sudo systemctl restart php${PHP_VERSION}-fpm
systemctl --no-pager --lines=0 status php${PHP_VERSION}-fpm
ls -l /run/php/php${PHP_VERSION}-fpm-${SITE}.sock       # srw-rw---- nginx nginx

9.1 Sizing pm.max_children — the part that bites #

This is the setting most likely to take a site down, in either direction. Two real failure modes, both observed in production:

  • Set too high. A 2 GB box with pm.max_children = 150 is promising 150 workers of ~30 MB each — 4.5 GB it does not have. Under load it exhausts RAM, thrashes swap, and the entire machine becomes unresponsive, requiring a reboot.
  • Set too low. The same box capped at 20 stopped hanging, but began returning HTTP 502 under load: every worker busy, the socket’s accept queue full, nginx refused instantly. The error to look for is connect() to unix:/run/php/... failed (11: Resource temporarily unavailable).

The second failure is the correct one. A queued or refused request is recoverable; an out-of-memory hang is not. Size it deliberately:

text
pm.max_children  ≈  (RAM available to PHP) / (average worker RSS)

Measure the real figure rather than guessing. The count includes the master process, and RSS counts shared opcache pages against every worker, so this overstates — it is a conservative number, which is the right kind:

bash
ps -C php-fpm${PHP_VERSION} -o rss= | awk '{s+=$1; n++} END {printf "avg %.0f MB across %d processes\n", s/n/1024, n}'

On a 2 GB box with MariaDB and ~30 MB workers, 20–30 is realistic. If you genuinely need more concurrency, add RAM — do not raise the ceiling and hope.

Two related settings worth reviewing: pm.max_requests recycles workers to bound leaks, and nginx’s fastcgi_read_timeout, which if left at something like 1800s lets a single stuck request hold a worker for half an hour.

10. The site’s server block #

/etc/nginx/conf.d/site1.conf
# literals to change: site1, site1.example.com, php8.5 in the socket path
# Rate-limit zone for the login endpoint (section 12.2). Zone names are global — one per site.
limit_req_zone $binary_remote_addr zone=site1_login:10m rate=6r/m;

server {
    server_name site1.example.com;

    listen 443 ssl;
    listen [::]:443 ssl;
    listen 443 quic reuseport;          # HTTP/3 — reuseport on exactly one server per address:port
    listen [::]:443 quic reuseport;
    http2 on;

    ssl_certificate     /etc/letsencrypt/live/site1.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site1.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_tickets off;

    root  /var/www/site1/public;
    index index.php index.html;
    server_tokens off;
    client_max_body_size 64m;

    # Advertise HTTP/3 to clients arriving over TCP
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy        "geolocation=(), microphone=(), camera=()" always;

    # Never serve dotfiles — .git, .env and friends (the ACME path lives on port 80)
    location ~ /\.(git|svn|env|ht) { return 404; }

    # WordPress endpoints that should not be public
    location ~* ^/(?:xmlrpc\.php|wp-config\.php|wp-links-opml\.php|readme\.html|license\.txt)$ { return 404; }

    # No PHP execution under uploads — the single most valuable WordPress rule.
    # Deliberately no trailing $: /uploads/x.php/y.jpg must match too (section 12.1)
    location ~* ^/wp-content/uploads/.*\.php { return 404; }

    location / { try_files $uri $uri/ /index.php?$args; }

    location = /wp-login.php {
        limit_req zone=site1_login burst=3 nodelay;
        include        fastcgi_params;
        fastcgi_pass   unix:/run/php/php8.5-fpm-site1.sock;
        fastcgi_param  SCRIPT_FILENAME /public$fastcgi_script_name;
        fastcgi_param  HTTP_PROXY "";
    }

    location ~ [^/]\.php(/|$) {
        try_files      $uri =404;        # the file must exist exactly as requested — no PATH_INFO tricks
        include        fastcgi_params;
        fastcgi_pass   unix:/run/php/php8.5-fpm-site1.sock;
        fastcgi_param  SCRIPT_FILENAME /public$fastcgi_script_name;
        fastcgi_param  HTTP_PROXY "";
        fastcgi_read_timeout 300s;
    }
}

Three details that catch people out:

  • SCRIPT_FILENAME is /public$fastcgi_script_name, not $document_root$fastcgi_script_name. nginx is outside the jail and PHP is inside it, so they do not agree on what / means. try_files, on the other hand, runs in nginx and checks the real path — which is exactly what makes it a useful guard.
  • listen 443 quic reuseport belongs on exactly one server block per address and port. The catch-all in section 7 deliberately has no QUIC listener.
  • includeSubDomains on the HSTS header commits every subdomain of the site to HTTPS, browsers remember it for a year, and there is no undo on the client side. Drop it if any subdomain is served plain.
bash
sudo nginx -t && sudo systemctl reload nginx
curl -sSI https://${DOMAIN}/ | grep -i -E '^(HTTP|alt-svc|strict-transport)'

The Alt-Svc line proves HTTP/3 is advertised. Proving it is used needs a QUIC-capable client — a browser’s network panel, or curl --http3-only where your curl was built with it.

11. WordPress, inside the jail #

bash
curl -fsSL https://wordpress.org/latest.tar.gz | sudo tar -xz -C ${JAIL}/public --strip-components=1
sudo chown -R ${SITE}:${SITE} ${JAIL}/public

Open https://site1.example.com/ and run the installer. When it asks for the database host, enter 127.0.0.1, not localhost: to a MySQL client, localhost means the Unix socket, and the socket is not inside the jail. It is the detail that trips up more first installs than any other.

Because PHP runs as the files’ owner, WordPress updates itself directly — no FTP credentials, no FS_METHOD workarounds. Two lines belong in wp-config.php before the site goes anywhere near the public:

wp-config.php
define( 'DISALLOW_FILE_EDIT', true );   // the built-in theme/plugin editor is a web shell with a login page
define( 'WP_AUTO_UPDATE_CORE', 'minor' );

12. Hardening beyond the stack #

12.1 Verify the rules, do not assume them #

.git in a web root exposes your entire source history, and it is actively scanned for. The uploads rule is the one most often bypassed — a request like /wp-content/uploads/x.php/y.jpg slips past a pattern anchored on .php$, and PHP’s path-info handling would happily execute x.php. Both are closed above; check:

bash
curl -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/.git/config                         # want 404
curl -o /dev/null -w '%{http_code}\n' https://${DOMAIN}/wp-content/uploads/x.php/y.jpg      # want 404

12.2 Rate-limit the login endpoint #

6r/m with burst=3 is generous for humans and hostile to credential stuffing. Bot traffic hitting wp-login.php otherwise consumes the very PHP workers you carefully sized in section 9.1. xmlrpc.php is denied outright; if you use the WordPress mobile app or Jetpack, both need it, so allow it with the same limit_req instead of the 404.

12.3 Keep admin tooling off the public interface #

phpMyAdmin and its relatives should not listen on the public interface at all — they are a second login page with the same password value as the first. For most administration sudo mariadb over SSH is enough; if you must have a browser tool, bind it to loopback and reach it through an SSH tunnel:

bash
ssh -L 8081:127.0.0.1:8081 admin@${DOMAIN}     # then open http://localhost:8081

12.4 Verify the jail actually holds #

A test run from the shell proves nothing here: the command-line PHP is not chrooted and does not read the pool’s disable_functions. The only honest test runs through nginx and the pool. Drop a probe into the document root, fetch it, delete it:

bash
sudo -u ${SITE} tee ${JAIL}/public/probe.php >/dev/null <<'PHP'
<?php
header('Content-Type: text/plain');
echo "root sees:   ", implode(' ', array_diff(scandir('/'), ['.', '..'])), "\n";   // the jail, not the host
echo "/etc/passwd: ", file_exists('/etc/passwd') ? 'VISIBLE' : 'not here', "\n";  // want: not here
echo "shell_exec:  ", function_exists('shell_exec') ? 'ENABLED' : 'disabled', "\n"; // want: disabled
echo "uid:         ", posix_geteuid(), "\n";                                       // the site user's uid
PHP
curl -s https://${DOMAIN}/probe.php
sudo rm ${JAIL}/public/probe.php

13. Backups #

A stack article that stops at “the site is up” is only half the job. Three rules worth more than any particular tool:

  1. Verify before you promote. Check the dump’s exit status (in a script, set -o pipefail, or a failed dump hides behind a successful gzip), its size, that the gzip stream is intact, and that the dump wrote its -- Dump completed trailer. That last one catches mid-stream truncation nothing else will.
  2. Never prune on age alone. Rotate only after a verified-good backup exists, and keep a floor of N copies. Otherwise a run of silent failures deletes your good backups and replaces them with junk.
  3. Test a restore. Restore into a scratch database and compare table and row counts against production. An untested backup is a hypothesis.
bash
# Consistent AND non-blocking — but only if the schema is InnoDB throughout (see below)
mariadb-dump --single-transaction --quick --routines --triggers --events \
             --default-character-set=utf8mb4 --databases ${SITE} | gzip > dump.sql.gz

gzip -t dump.sql.gz                                          # integrity
gzip -dc dump.sql.gz | tail -c 512 | grep 'Dump completed'   # not truncated

Warning

--single-transaction snapshots transactional engines only (InnoDB). On a schema containing MyISAM tables it is non-blocking but inconsistent — worse than the default, because it looks correct. Check before you trust it:

SELECT engine, COUNT(*) FROM information_schema.tables WHERE table_schema = 'site1' GROUP BY engine;

Legacy WordPress schemas are often MyISAM-heavy. MariaDB’s Aria engine is the crash-safe successor, and converting those tables (to InnoDB or Aria) both hardens them and makes --single-transaction actually mean something. Do it deliberately, on a copy first.

Files matter as much as the database: uploads, themes and wp-config.php are not in any SQL dump. rsync --link-dest hard-link snapshots give you a full-looking daily copy for the cost of what changed.

And a backup on the same disk is not disaster recovery — it protects against deletion and bad deploys, not against losing the machine.


Need this built, or an existing WordPress estate brought up to it? WordPress engineering and hosting is one of the things we do, and the panel below reaches the people who do the work.

Contact us

Have a hard problem and a budget?

Tell us what's driving it. You'll talk to the people who do the work.

A sentence or two is plenty: the problem, the system, the deadline.