WP-CLI tutorial: manage and harden WordPress from the command line

Install WP-CLI, then update WordPress, manage plugins and users, move a site to a new domain, back up the database and lock the site down, all over SSH.

Tested on Ubuntu 24.04 LTS

Before you start

  • A WordPress site on a server you can SSH into (Ubuntu 24.04 or Debian 12 or 13)
  • Root or a user with sudo
  • PHP's command-line binary (php-cli)

WP-CLI is the official command-line tool for WordPress: you install it as a single file (wp), then run commands such as wp core update, wp plugin update --all and wp db export over SSH instead of clicking through the dashboard. This tutorial installs it, shows the commands you'll use every week, and uses them to harden the site: no file editor in the dashboard, fresh salts, checksum checks, real cron and, optionally, files the web server can't change.

It assumes WordPress is already installed. If it isn't, host WordPress on a VPS sets it up with nginx, PHP-FPM and MariaDB; the paths below (/var/www/wordpress, files owned by www-data) match that guide. Change them if your site lives elsewhere.

Install WP-CLI

WP-CLI needs PHP's command-line binary. On Ubuntu and Debian:

bash
sudo apt install -y php-cli curl

Download WP-CLI, check it against the published checksum, and put it on your path as wp:

bash
cd /tmp
curl -fsSLO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
curl -fsSLO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar.sha512
echo "$(cat wp-cli.phar.sha512)  wp-cli.phar" | sha512sum -c
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp

Check it worked. sha512sum -c prints wp-cli.phar: OK, and wp --version prints the version (WP-CLI 2.12.0 when we wrote this). Update it later with sudo wp cli update.

Run it as the site's owner

Run WP-CLI as the user that owns the WordPress files, never as root. As root, WP-CLI refuses with:

text
Error: YIKES! It looks like you're running this as root. You probably meant to run this as the user that your WordPress installation exists under.

It's right to: WP-CLI loads the site's plugins, so a bad plugin would run with root's power, and files it creates would belong to root, which then breaks updates from the dashboard.

With the files owned by www-data, give WP-CLI somewhere to keep its download cache (www-data's home is /var/www, which it can't write to), and add an alias to your own ~/.bashrc so every wp command runs as www-data against the site:

bash
sudo install -d -o www-data -g www-data /var/www/.wp-cli
echo "alias wp='sudo -u www-data wp --path=/var/www/wordpress'" >> ~/.bashrc
source ~/.bashrc

All commands below use this alias. Without it, write them out in full: sudo -u www-data wp --path=/var/www/wordpress core version.

Check it worked.

bash
wp core version
wp option get siteurl

They print the WordPress version and your site's address. Error: This does not seem to be a WordPress installation. means the --path is wrong.

Update WordPress, plugins and themes

See what's out of date:

bash
wp core check-update
wp plugin list --update=available
wp theme list --update=available

Export the database first (next section), then update everything:

bash
wp core update
wp core update-db
wp plugin update --all
wp theme update --all
wp language core update

wp core update-db runs the database upgrade a new WordPress version sometimes needs; the dashboard does this after a click, WP-CLI waits for you to ask. wp plugin update --all --dry-run lists what would change without changing it.

To let WordPress update the plugins by itself between your visits:

bash
wp plugin auto-updates enable --all

Check it worked. wp core check-update says Success: WordPress is at the latest version., and wp plugin list shows none in the update column for every plugin.

Manage plugins, themes and users

bash
wp plugin install classic-editor --activate   # install and turn on, by its wordpress.org slug
wp plugin deactivate classic-editor
wp plugin delete classic-editor
wp theme activate twentytwentyfive

Unused plugins and themes are still code on the server that an attacker can reach, even when they're turned off. List the inactive ones, check the list, then delete them:

bash
wp plugin list --status=inactive --field=name
wp plugin delete $(wp plugin list --status=inactive --field=name)
wp theme delete $(wp theme list --status=inactive --field=name)

Keep one default theme (twentytwentyfive or similar) if your active theme is a child theme or you want something to fall back to.

Users:

bash
wp user list --role=administrator --fields=ID,user_login,user_email
wp user create priya priya@example.com --role=editor
wp user update maya --user_pass='a-long-new-password'
wp user session destroy maya --all

wp user create prints the new user's generated password. wp user session destroy --all logs a user out everywhere, which is what you want after a password change you made because of a suspected leak. Anyone listed as administrator who shouldn't be is the first thing to remove after a break-in.

Back up and restore the database

bash
wp db export - | gzip > ~/wp-db-$(date +%F).sql.gz

- writes the dump to standard output, so it lands in your own home folder (compressed) rather than inside the web root. To restore it:

bash
gunzip -c ~/wp-db-2026-10-21.sql.gz | wp db import -

A full backup is the database plus wp-content (uploads, plugins, themes), and it belongs on another machine: back up your server covers copying both off the server every night.

Check it worked. gunzip -c ~/wp-db-*.sql.gz | head -5 shows the start of a MariaDB dump, and wp db size --human-readable shows how big the live database is, for comparison.

Move the site to a new domain

WordPress stores its own address, in full, all over the database: in options, in post content, in serialized plugin settings. A plain SQL REPLACE breaks the serialized values; wp search-replace rewrites them correctly.

Export the database first. Then look before you change anything:

bash
wp search-replace 'https://example.com' 'https://www.example.com' --skip-columns=guid --dry-run --report-changed-only

It lists each table and column with the number of replacements. If it looks right, run it for real, without --dry-run:

bash
wp search-replace 'https://example.com' 'https://www.example.com' --skip-columns=guid
wp cache flush

--skip-columns=guid leaves post GUIDs alone: they're permanent IDs that feed readers use, not links. Include the scheme (https://) in both strings so you don't half-replace other text.

Check it worked. wp option get home and wp option get siteurl print the new address. Update the domain in your nginx config and certificate as well.

Harden the site

Each of these is one command. Together they close the most common ways a WordPress site gets taken over.

Turn off the theme and plugin file editor. Anyone who gets an administrator's password can otherwise paste PHP straight into a theme file from the dashboard:

bash
wp config set DISALLOW_FILE_EDIT true --raw

Replace the salts. The keys in wp-config.php sign login cookies. New ones log everyone out and make stolen cookies useless. Do it after any suspected break-in, or if your wp-config.php was ever shared:

bash
wp config shuffle-salts

Check the files against wordpress.org. This compares every core file, and every plugin from the plugin directory, with the published checksums. A modified or extra file is a classic sign of a backdoor:

bash
wp core verify-checksums
wp plugin verify-checksums --all

Both should end with Success. Premium plugins that aren't on wordpress.org can't be checked and are reported as skipped.

Run WordPress's scheduled tasks from real cron. WP-Cron normally runs scheduled jobs (publishing, update checks, plugin tasks) only when someone visits the site, so a quiet site misses them and a busy one runs the check on every request. Turn that off and run it every five minutes instead:

bash
wp config set DISABLE_WP_CRON true --raw
echo '*/5 * * * * www-data /usr/local/bin/wp --path=/var/www/wordpress cron event run --due-now --quiet' | sudo tee /etc/cron.d/wordpress

wp cron event run --due-now prints Success: Executed a total of N cron events. when you run it by hand.

Keep PHP itself up to date as well: WordPress is only as safe as the PHP under it. The PHP2Shell guide shows how to check and patch it.

Optional: make the code read-only for the web server

The setup above lets www-data, the user PHP runs as, write every file of the site. That's what makes one-click updates in the dashboard work, and it's also what lets a hole in one plugin rewrite all the others. You can take that away: files owned by a separate user, with only wp-content/uploads writable by the web server.

bash
sudo useradd -m -s /bin/bash wpowner
sudo chown -R wpowner:wpowner /var/www/wordpress
sudo chown -R www-data:www-data /var/www/wordpress/wp-content/uploads
sudo find /var/www/wordpress -type d -exec chmod 755 {} +
sudo find /var/www/wordpress -type f -exec chmod 644 {} +
sudo chgrp www-data /var/www/wordpress/wp-config.php
sudo chmod 640 /var/www/wordpress/wp-config.php

wp-config.php holds the database password, so only its owner and the web server's group can read it.

Then tell WordPress to stop offering installs and updates it can no longer do, and switch your alias to the new owner:

bash
sudo -u wpowner wp --path=/var/www/wordpress config set DISALLOW_FILE_MODS true --raw
sed -i "s/sudo -u www-data wp/sudo -u wpowner wp/" ~/.bashrc && source ~/.bashrc

The trade-off: with DISALLOW_FILE_MODS, WordPress no longer installs anything by itself, including its automatic security releases. WP-CLI ignores the setting, so updates are now your job: run them weekly (or daily) from the owner's crontab, sudo crontab -u wpowner -e:

text
30 4 * * * /usr/local/bin/wp --path=/var/www/wordpress core update --minor --quiet && /usr/local/bin/wp --path=/var/www/wordpress plugin update --all --quiet

Update the cron line from the previous section to run as wpowner too. Some plugins (caching and security plugins especially) want to write their own files outside uploads; give them their folder only, not the whole tree.

Check it worked. sudo -u www-data touch /var/www/wordpress/wp-content/plugins/test fails with Permission denied, while uploading an image in the dashboard still works.

Fix the common problems

Error: Error establishing a database connection. WP-CLI reads the same wp-config.php as the site, so the database settings are wrong for both, or MariaDB is down: systemctl status mariadb. If the site works in a browser but WP-CLI fails, DB_HOST may be localhost and the CLI's PHP can't find the socket; check php -i | grep mysqli.default_socket.

Warning: Failed to create directory '/var/www/.wp-cli/cache/': mkdir(): Permission denied. The cache folder is missing. Create it as in "Run it as the site's owner", or ignore it: it only means downloads aren't cached.

wp core verify-checksums lists many File doesn't exist: wp-includes/php-ai-client/... lines. We saw this on WordPress 7.1.3 installed with wp core download (WP-CLI 2.12.0): files with very long paths didn't come out of the archive. Sites installed from the latest.tar.gz download, as in the WordPress guide, verify cleanly. To repair, replace the two core folders from the official archive for your version:

bash
V=$(wp core version)
cd /tmp && curl -fsSLO "https://wordpress.org/wordpress-$V.tar.gz" && tar xzf "wordpress-$V.tar.gz"
sudo rsync -a --delete --chown=www-data:www-data /tmp/wordpress/wp-admin/ /var/www/wordpress/wp-admin/
sudo rsync -a --delete --chown=www-data:www-data /tmp/wordpress/wp-includes/ /var/www/wordpress/wp-includes/
wp core verify-checksums

Use the right owner in --chown if you made the code read-only.

PHP Fatal error: Allowed memory size of ... exhausted. Some commands (big search-replace runs, imports) need more memory than the CLI's default. Run them with more: sudo -u www-data php -d memory_limit=512M /usr/local/bin/wp --path=/var/www/wordpress ....

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee