Server-Side WordPress Tuning

What it does

A re-runnable, root-run Bash tool that applies server-level WordPress performance and hardening tuning — no WordPress plugins involved. It auto-discovers every WordPress install under a base directory (default /var/www, override with WP_SITES), and every optimization is an individually toggleable task. Run it plain for an interactive picker, or automate a subset with flags. It’s meant to run after a site has already been installed (e.g. with an LEMP/LAMP installer script) — it operates on whatever WordPress sites it finds already on the box, it doesn’t install WordPress itself.

A full backup (database + wp-content) always runs first, before any task, regardless of which tasks are selected — it is not optional and cannot be skipped.

Requirements

  • Debian/Ubuntu, run as root (sudo ./wp-optimize.sh)
  • An existing nginx or Apache install with at least one WordPress site already deployed under the scan base directory
  • whiptail is used for the interactive menu if present, but isn’t required — it falls back to a plain numbered toggle menu with no extra dependencies
  • WP-CLI (wp) is optional but improves a couple of tasks (e.g. wp_cron upgrades to a more reliable invocation if WP-CLI is installed; dbcleanup can use it where available)

Usage

sudo ./wp-optimize.sh                          # interactive menu
sudo ./wp-optimize.sh --all                     # every default-on task, no prompts
sudo ./wp-optimize.sh --all-including-optional  # literally every task, including opt-in ones
sudo ./wp-optimize.sh --only opcache,redis,dbcleanup
sudo ./wp-optimize.sh --list-tasks              # list every task, description, and default
sudo ./wp-optimize.sh --dry-run [--all | --only <tasks>]
sudo ./wp-optimize.sh --backup-only             # take a full backup, change nothing
sudo ./wp-optimize.sh --backup-db-only          # database-only backup, change nothing
sudo ./wp-optimize.sh --list-backups
sudo ./wp-optimize.sh --restore <site> [timestamp] [db|files|both]
sudo ./wp-optimize.sh --cleanup-restore-dbs     # drop leftover temp restore databases (interactive)
sudo ./wp-optimize.sh --cleanup-orphaned-cron   # remove cron jobs left behind by deleted sites
sudo ./wp-optimize.sh --undo [tasks]            # reverse previously applied tasks
sudo ./wp-optimize.sh --install-cron ["<schedule>" tasks]   # schedule this script itself via cron
sudo ./wp-optimize.sh --remove-cron
sudo ./wp-optimize.sh --sites site1,site2 ...   # narrow to specific sites (combine with any mode above)

Override the search base or give explicit site paths:

WWW_BASE=/srv/sites ./wp-optimize.sh
WP_SITES="/var/www/html/site-a /var/www/html/site-b" ./wp-optimize.sh

Full task list

Everything below is on by default unless marked opt-in. Use --list-tasks on your own box any time to see this same list plus whatever the current script version adds.

Task IDWhat it doesDefault
opcachePHP OPcache tuning (memory, file cache)On
fpm_poolPHP-FPM pool sizing, workers scaled to RAMOn
realpathPHP realpath cache tuningOn
jitOPcache JIT (PHP 8.0+, limited gain for WordPress)On
page_cacheFull-page caching (Apache mod_cache or nginx fastcgi_cache)On
compressiongzip + Brotli compressionOn
browser_cacheBrowser cache headers for static assetsOn
http2HTTP/2 on each site’s vhost (nginx: only if TLS is already set up)On
mariadbMariaDB/MySQL tuning (buffer pool, connections)On
redisRedis object cache, per-site, safe fallback if Redis isn’t reachableOn
wp_cronWP-Cron handling — you choose one of 3 modes (see below)On
wp_revisionsCap post revisions to 3 + tune the autosave intervalOn
dbcleanupDatabase cleanup: old revisions, transients, spam, orphaned rowsOn
xmlrpcDisable XML-RPC — blocks a common bot/DDoS vector; skip if you use Jetpack or legacy mobile appsOpt-in
heartbeatThrottle the Heartbeat API (mu-plugin, not a marketplace plugin)On
sysctl_netLinux kernel network tuning (somaxconn, tcp_fin_timeout, tcp_tw_reuse)Opt-in
block_uploads_phpBlock PHP execution inside wp-content/uploads — prevents webshell uploads, doesn’t affect what you can uploadOn
security_headersSecurity headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, hides server versionOn
disable_author_enumBlocks ?author=N username enumerationOn
disable_rest_user_enumBlocks anonymous /wp-json/wp/v2/users enumeration — logged-in/admin use unaffected; skip if you rely on public author data via REST (e.g. a headless frontend)On
disable_directory_listingDisables directory listing (Apache Options -Indexes / nginx autoindex off)On
hide_info_filesBlocks direct access to readme.html, license.txt, wp-config-sample.phpOn
disallow_file_editDisables the wp-admin theme/plugin file editorOn
remove_emojiRemoves WordPress’s emoji detection script/styles (mu-plugin)On
force_ssl_adminForces SSL for wp-admin/login — only applied if TLS is actually confirmed set upOn
wp_saltsGenerates real random auth keys/salts if the site still has placeholder text — logs everyone out onceOn
reverse_proxy_httpsTrusts X-Forwarded-Proto from an upstream TLS-terminating proxy (Traefik/nginx/Cloudflare/ALB); harmless if you’re not behind oneOn
hstsHSTS header — only offered once TLS is confirmed for a site, asks its own separate yes/no since it’s sticky/hard to undoOpt-in
file_permissionsStrips world-read from wp-config.php and world-write from any file in the site, without touching owner/group bitsOn
fail2banInstalls and enables fail2ban with an sshd jail — a repeated-failed-login ban can lock out your own IP tooOpt-in
auto_updatesEnables unattended-upgrades for automatic OS security patches only (package installs/reboots are NOT enabled)On
ssh_hardeningPermitRootLogin prohibit-password (root: key-only login); PasswordAuthentication stays yes for other accountsOpt-in

Things you need to know before running it

Non-interactive runs only apply default-on tasks

Run with no arguments in a session with no TTY (e.g. from cron or a CI job) and it auto-selects everything marked “on by default” above — the opt-in tasks (xmlrpc, sysctl_net, fail2ban, ssh_hardening, hsts) are skipped, not silently enabled. For an unattended run that includes an opt-in task, use --only explicitly.

Two tasks can lock you out — and both ask before doing anything

  • fail2ban: enabling an sshd jail means repeated failed logins from an IP get banned — including yours, if you mistype your own password enough times. Confirm your SSH access is solid (ideally key-based) before turning this on.
  • ssh_hardening: sets root to key-only login. If root has no working SSH key configured, this locks you out of root over SSH entirely. It’s opt-in and asks an explicit “does root already have a working key?” confirmation interactively; non-interactively it requires no confirmation mechanism at all and is simply skipped with a warning rather than risk locking you out unattended.

wp_salts logs every logged-in user out, once

If a site still has the placeholder AUTH_KEY/salt values, this task replaces them with real random ones. That invalidates all existing sessions/cookies — expected, and only happens the one time it actually needs to generate real keys.

hsts is opt-in and deliberately hard to undo

Browsers cache Strict-Transport-Security for its max-age, so turning it on for a site that later loses its HTTPS setup can leave visitors’ browsers refusing to load it over plain HTTP for a long time. It’s only offered once TLS is genuinely confirmed for that specific site, and needs its own separate yes/no on top of the TLS confirmation — “TLS is live” is never treated as implicit permission to enable HSTS.

force_ssl_admin / reverse_proxy_https / hsts require you to confirm a proxy is actually live — per site

If a site sits behind a TLS-terminating reverse proxy (Traefik, Cloudflare, an ALB, etc.) rather than serving HTTPS itself, these tasks need to know that to be safe. The script only trusts a genuinely foreign X-Forwarded-Proto block in wp-config.php as evidence — never its own reverse_proxy_https marker, since that gets added to every site unconditionally and proves nothing about whether a proxy is actually in front of it. Even a real foreign block only proves the code exists, not that the proxy is live right now, so you’re asked to confirm per site (interactively) or set WP_TRUST_PROXY_TLS=yes/no for unattended runs. This confirmation is never reused across different sites in the same run — each site gets asked, or is covered individually by the env var.

Some tasks are whole-server, not per-site — even with --sites

opcache, fpm_pool, realpath, jit, mariadb, compression, and sysctl_net tune one PHP-FPM pool, one MariaDB instance, and one kernel per box. Applying or undoing them while --sites-scoped still affects the entire server, not just the named site(s) — the script prints an explicit NOTE: when this happens.

fpm_pool doesn’t recalculate automatically after you resize the server’s RAM

Most RAM-based tuning rewrites itself fresh from current memory on every run, so a plain --all re-run after a RAM change picks up the new total. fpm_pool is the exception: once its config block exists, later runs see it and skip rather than recalculating, so pm.max_children etc. stay frozen at whatever was computed the first time. After resizing RAM, force a recalculation with:

sudo ./wp-optimize.sh --undo fpm_pool
sudo ./wp-optimize.sh --only fpm_pool

Choose the right WP-Cron mode

The wp_cron task asks how scheduled WordPress tasks (auto-updates, scheduled posts, etc.) should actually run:

  1. Replace with real system cron (default/recommended) — disables the page-load-triggered pseudo-cron and runs wp-cron.php every 5 minutes via the OS instead. Doesn’t disable auto-updates or scheduled posts.
  2. Leave WordPress’s native behavior alone — fires on page load, as WordPress does by default. Nothing changes.
  3. Disable entirely, no replacement — scheduled tasks including auto-updates will NOT run unless you trigger wp-cron.php yourself (e.g. via an external uptime monitor). Only choose this if you already have another trigger set up.

In a non-interactive session it defaults to mode 1 (replace). Set WP_CRON_MODE=leave or WP_CRON_MODE=disable to override.

xmlrpc and disable_rest_user_enum can break specific integrations

Disabling XML-RPC (opt-in, off by default) will break Jetpack and some legacy mobile apps that depend on it. Blocking anonymous REST user enumeration doesn’t affect logged-in users or the admin UI, but will break a headless frontend or integration that reads public author data through the REST API while logged out.

An older version of this script used a different config marker

A legacy version wrote wp_cron and wp_revisions as one combined marker block in wp-config.php. The current version detects that old marker and refuses to touch the file — it warns instead of risking a duplicate define() — so a site tuned by an old copy of this script needs a manual look at its wp-config.php before those two tasks will apply cleanly.

Restoring from backup is destructive and isn’t backed up first

--restore overwrites the site’s current database and/or wp-content with the chosen backup. This cannot be undone unless you take a fresh backup of the current state first — the script does not do that automatically. It requires you to type yes to confirm before proceeding.

Orphaned cron jobs need manual cleanup

If a site is deleted from disk without first running --undo wp_cron on it, its /etc/cron.d/wp-cron-<site> file keeps firing forever against a path that no longer exists (silently, since its output goes to /dev/null). A normal run nudges you if any exist; run --cleanup-orphaned-cron to review and remove them (interactive confirmation, nothing is deleted automatically).

Backups and where things are saved

  • Full site backups (DB + wp-content) live under /var/backups/wp-optimize/<site>/<timestamp>/, default retention KEEP_BACKUPS=5 per site
  • Per-site config file edits (wp-config.php, .htaccess) are backed up into the site’s own backup directory, not left in the web root
  • Shared server-level config (PHP-FPM pool, vhosts) gets a .wp-optimize.bak copy alongside the original instead
  • Redis credentials (if generated) are saved to /root/wp-optimize-redis-credentials.txt
  • Inserted config blocks are marker-delimited, so re-running skips what it already added instead of duplicating it

A safe way to run it the first time

  1. Preview everything first: sudo ./wp-optimize.sh --dry-run --all — nothing is written, restarted, or backed up, but every task’s own “already applied?” detection runs for real so you see exactly what would change.
  2. Run --list-tasks and decide if any opt-in tasks (xmlrpc, hsts, sysctl_net, fail2ban, ssh_hardening) apply to you — and confirm SSH access is solid before enabling fail2ban or ssh_hardening.
  3. Run the real thing: interactive (sudo ./wp-optimize.sh) if you want to pick task-by-task, or --all if the defaults above are fine.
  4. Test each site afterward. Clear the shared page cache with rm -rf /var/cache/apache2/mod_cache_disk/* (Apache) if pages look stale, and a site’s Redis object cache with redis-cli -n <db_index> FLUSHDB if needed.
  5. Know how to back out: --list-backups to see what’s available, --restore <site> [timestamp] to roll back, --undo [tasks] to reverse specific tasks.

Leave a Reply

Your email address will not be published. Required fields are marked *