The Seven Commands I Actually Run Every Day (And Why I Stopped Memorizing Them)
For a long time, my daily WordPress ops routine lived in shell history. Pull a database, sync some uploads, eyeball a security log, glance at a client site’s traffic — each one a slightly-different ssh one-liner or ansible-playbook invocation I’d half-remember and then grep my own history for. Multiply that by a dozen client sites on Trellis, each with its own site key, and “half-remember” starts costing real minutes every single day.
The fix wasn’t a better memory. It was collapsing everything into one catalog: wp-ops, a single CLI that wraps the Ansible playbooks, WP-CLI scripts, and shell utilities I’d built up over time into one binary with real --help, search, and shell completions. Here’s the actual loop I run against it — daily and weekly — and what each command is doing underneath.
Why one binary instead of a folder of scripts
Before wp-ops existed, this was a directory of ~74 things: Ansible playbooks under trellis/, standalone Bash under scripts/, WP-CLI PHP under wp-cli/. All useful, all discoverable only if you already knew the filename. The CLI auto-discovers all of it from manifest comments at the top of each script:
# @desc Back up a site's database from any environment
# @category backup
# @platform trellis
# @requires ansible-playbook
# @example wp-ops database-backup imagewize.com production
That manifest is the whole trick — wp-ops --help, wp-ops search, and shell completions are all generated from it, so there’s no second list to keep in sync. Run wp-ops doctor once after installing and it tells you which of the underlying tools (WP-CLI, Ansible, gh, ImageMagick…) are actually on your machine before a command fails halfway through.
brew install imagewize/tap/wp-ops
wp-ops init # shell completions
wp-ops doctor # check dependencies
With that in place, here’s the loop. The examples below use our own site keys — imagewize.com for the main site, demo.imagewize.com for our local pattern-showcase install — swap in whatever key matches your project’s entry in wordpress_sites.yml.
1. Database backup
wp-ops database-backup imagewize.com production
Runs trellis/backup/database-backup.yml against the target environment: wp db export on the remote, gzipped, pulled down as a timestamped imagewize_com_production_2026_08_29_10_00_00.sql.gz. I run this before anything that touches production data, not just on a schedule.
2. Database pull, with URL search-replace already done
wp-ops database-pull imagewize.com production
This is the one that looks simple and isn’t. database-pull.yml doesn’t just fetch a dump — it backs up your current local database first, into a database_backup/ folder inside the project, before it imports anything. That’s easy to miss because it’s buried in the middle of the playbook, but it’s the difference between “oops, wrong environment” being an inconvenience versus a lost afternoon.
After the import it runs the URL swap itself:
url_from: "{{ wordpress_sites[site].site_hosts.0.canonical }}"
url_to: "{{ dev_wordpress_sites.wordpress_sites[site].site_hosts.0.canonical }}"
resolving both sides from group_vars — production’s canonical hostname and your local local_path config — so wp search-replace runs with the right values without you typing either URL. I wrote up the playbook’s internals (and two bugs I had to patch in the stock version) in an earlier post if you want the full mechanics.
3. Files pull via rsync
wp-ops files-pull imagewize.com production
Syncs shared/uploads/ from the remote into web/app/uploads/ locally. It’s additive by default — local-only files (test images, things since deleted from the remote media library) survive the sync. Add --delete yes only when you actually want local to mirror remote exactly; that flag is destructive on the local side, so I don’t reach for it without a reason.
4. Dropping a pattern onto a page to test it
This one isn’t a single wp-ops command yet — it’s a wp post update run against the Trellis VM, and it’s the step I reach for most when a new block pattern needs a real page to render on before it ships. I do this against demo.imagewize.com, our local showcase site, rather than risk it on a client project:
trellis vm shell --workdir /srv/www/demo.imagewize.com/current -- bash << 'VMEOF'
cat > /tmp/page-content.html << 'EOF'
<!-- wp:pattern {"slug":"nynaeve/pattern-slug"} /-->
EOF
CONTENT=$(cat /tmp/page-content.html)
wp post update PAGE_ID --post_content="$CONTENT" --url=https://demo.imagewize.test/ --path=web/wp
VMEOF
The trap here isn’t the command, it’s what happens after: any pattern using get_template_directory_uri() bakes an environment-specific URL straight into post_content — http://demo.imagewize.test/app/themes/nynaeve/patterns/images/foo.webp. That’s fine on local. It is not fine if that content ever reaches production without a search-replace pass first, since it’ll silently serve broken images or trip mixed-content warnings. I run a URL audit before anything with test patterns in it goes near a production database — worth building into your own checklist if you do content work this way.
5. Security checkup
wp-ops security-scan imagewize.com production
wp-ops security-scan imagewize.com production --hours 48 --threshold 50
Scans Nginx access logs on the target for attack signatures — repeated wp-login.php hits, SQL-injection strings, scanner user-agents — over a configurable window, flagging any IP over the request threshold. For file-level malware checks instead of log analysis, that’s a separate WP-CLI pass: wp eval-file wp-cli/security/scanner-wrapper.php run on the server, which is closer to a monthly job than a daily one.
6. Monitor checkup
wp-ops quick-status imagewize.com production
for the fast version — recent status codes, error counts, service status, good for “is this site okay right now.” The Nginx and PHP-FPM status checks used to fail outright with Missing sudo password: the playbook had become: yes on those two tasks, but systemctl status is a read-only query that doesn’t need root, and there’s no password source when the command runs non-interactively. Dropped become from both tasks so they run as the regular deploy user, matching the other read-only monitoring playbooks in the repo.
Fixing that surfaced a second, quieter bug in the same task: systemctl status php*-fpm was silently returning nothing. Systemd’s unit-name globbing needs the full suffix, so php*-fpm never matched php8.4-fpm.service even though the unit was loaded and running — and it exited rc=0 with empty output instead of erroring, so it went unnoticed right alongside the sudo fix. Quoting the glob and adding .service ('php*-fpm.service') fixed it for good.
For traffic alone — no security or AI-crawler noise mixed in — there’s a narrower command:
wp-ops traffic-report imagewize.com production --hours 120
That’s the last five days, and on a real run against imagewize.com it surfaces exactly what you’d hope: the homepage and /contact/ up top (436 and 187 hits), then whichever blog post is actually pulling organic traffic that week — /how-to-audit-your-woocommerce-store/ at 131 hits over that window, well ahead of the wp-ops post itself at 43. It’s the same Nginx log parsing as the combined monitor.sh run below, minus the security and error-log sections — the one I reach for when I just want a pulse check on what’s resonating that week, not a full report.
One gotcha: traffic-report runs through ansible-playbook, so it needs TRELLIS_DIR pointed at your Trellis project (export TRELLIS_DIR=/path/to/trellis), or to be run from inside it — unlike quick-status and the other read-only checks above, which resolve that on their own.
One more thing worth knowing before you trust that “real user” number: if you routinely hit the site over a VPN while testing, that traffic counts as human too — the UA-based bot filter has no way to catch it. Pulling a six-week traffic trend on imagewize.com surfaced this directly: the same three IPs (146.70.14.43–45, a ProtonVPN Indonesia exit-node range I test through) kept showing up in “Top IP Addresses,” inflating some weekly windows by up to 8%. traffic-monitor.sh 5.14.0 adds an EXCLUDE_IP_PATTERN for exactly this — set it once to your own VPN or dev-IP range and every section of the report (totals, top pages, top IPs, the SEO breakdowns) excludes it automatically, no extra filtering pass needed.
When I want the fuller picture — traffic, security, AI-crawler activity, and error logs together, saved as a timestamped report — that’s:
ssh web@imagewize.com 'bash -s' < scripts/monitoring/monitor.sh
streamed over SSH stdin rather than installed on the server, so there’s nothing left behind to clean up afterward.
7. GitHub traffic across the main repos
wp-ops gh-traffic imagewize/wp-ops imagewize/nynaeve imagewize/aludra imagewize/elayne imagewize/aviendha imagewize/ixian --all
Pulls views, clones, and referrers for each repo via GitHub’s traffic API — --all gets you every section, --quiet drops table headers if you’re piping the output somewhere. GitHub only retains 14 days of this data, so I run it weekly rather than let a slow week’s numbers age out unseen. imagewize.com itself is private, so it’s left out here — the traffic API is really only worth checking on the public repos anyway.
The point of collapsing this into a CLI
None of these seven things were hard to run individually — every one of them already existed as a playbook or script before wp-ops did. What was hard was remembering which one and with what flags, across a dozen site keys, without pasting a domain into the wrong environment. Manifest-driven discovery means the catalog documents itself: wp-ops search backup, wp-ops <command> --help, and the interactive picker all read from the same source the commands themselves declare, so the list can’t drift out of sync with what’s actually runnable.
This is part of the tooling we run at Imagewize for client WordPress operations on Trellis. If your team is drowning in the same kind of scattered-script sprawl, get in touch.
Questions about your own Trellis workflow? Find me on Mastodon at @jfrumau@mastodon.social.