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
whiptailis 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_cronupgrades to a more reliable invocation if WP-CLI is installed;dbcleanupcan 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 ID | What it does | Default |
|---|---|---|
opcache | PHP OPcache tuning (memory, file cache) | On |
fpm_pool | PHP-FPM pool sizing, workers scaled to RAM | On |
realpath | PHP realpath cache tuning | On |
jit | OPcache JIT (PHP 8.0+, limited gain for WordPress) | On |
page_cache | Full-page caching (Apache mod_cache or nginx fastcgi_cache) | On |
compression | gzip + Brotli compression | On |
browser_cache | Browser cache headers for static assets | On |
http2 | HTTP/2 on each site’s vhost (nginx: only if TLS is already set up) | On |
mariadb | MariaDB/MySQL tuning (buffer pool, connections) | On |
redis | Redis object cache, per-site, safe fallback if Redis isn’t reachable | On |
wp_cron | WP-Cron handling — you choose one of 3 modes (see below) | On |
wp_revisions | Cap post revisions to 3 + tune the autosave interval | On |
dbcleanup | Database cleanup: old revisions, transients, spam, orphaned rows | On |
xmlrpc | Disable XML-RPC — blocks a common bot/DDoS vector; skip if you use Jetpack or legacy mobile apps | Opt-in |
heartbeat | Throttle the Heartbeat API (mu-plugin, not a marketplace plugin) | On |
sysctl_net | Linux kernel network tuning (somaxconn, tcp_fin_timeout, tcp_tw_reuse) | Opt-in |
block_uploads_php | Block PHP execution inside wp-content/uploads — prevents webshell uploads, doesn’t affect what you can upload | On |
security_headers | Security headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, hides server version | On |
disable_author_enum | Blocks ?author=N username enumeration | On |
disable_rest_user_enum | Blocks 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_listing | Disables directory listing (Apache Options -Indexes / nginx autoindex off) | On |
hide_info_files | Blocks direct access to readme.html, license.txt, wp-config-sample.php | On |
disallow_file_edit | Disables the wp-admin theme/plugin file editor | On |
remove_emoji | Removes WordPress’s emoji detection script/styles (mu-plugin) | On |
force_ssl_admin | Forces SSL for wp-admin/login — only applied if TLS is actually confirmed set up | On |
wp_salts | Generates real random auth keys/salts if the site still has placeholder text — logs everyone out once | On |
reverse_proxy_https | Trusts X-Forwarded-Proto from an upstream TLS-terminating proxy (Traefik/nginx/Cloudflare/ALB); harmless if you’re not behind one | On |
hsts | HSTS header — only offered once TLS is confirmed for a site, asks its own separate yes/no since it’s sticky/hard to undo | Opt-in |
file_permissions | Strips world-read from wp-config.php and world-write from any file in the site, without touching owner/group bits | On |
fail2ban | Installs and enables fail2ban with an sshd jail — a repeated-failed-login ban can lock out your own IP too | Opt-in |
auto_updates | Enables unattended-upgrades for automatic OS security patches only (package installs/reboots are NOT enabled) | On |
ssh_hardening | PermitRootLogin prohibit-password (root: key-only login); PasswordAuthentication stays yes for other accounts | Opt-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:
- Replace with real system cron (default/recommended) — disables the page-load-triggered pseudo-cron and runs
wp-cron.phpevery 5 minutes via the OS instead. Doesn’t disable auto-updates or scheduled posts. - Leave WordPress’s native behavior alone — fires on page load, as WordPress does by default. Nothing changes.
- Disable entirely, no replacement — scheduled tasks including auto-updates will NOT run unless you trigger
wp-cron.phpyourself (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 retentionKEEP_BACKUPS=5per 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.bakcopy 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
- 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. - Run
--list-tasksand decide if any opt-in tasks (xmlrpc,hsts,sysctl_net,fail2ban,ssh_hardening) apply to you — and confirm SSH access is solid before enablingfail2banorssh_hardening. - Run the real thing: interactive (
sudo ./wp-optimize.sh) if you want to pick task-by-task, or--allif the defaults above are fine. - 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 withredis-cli -n <db_index> FLUSHDBif needed. - Know how to back out:
--list-backupsto see what’s available,--restore <site> [timestamp]to roll back,--undo [tasks]to reverse specific tasks.