The .env File That Runs 18 WordPress Sites (No Paid MCP Required)
A plain text file on my laptop runs 18 WordPress sites. It costs nothing, it is a few kilobytes, and it does everything the paid AI-for-WordPress tools do, plus a category of work none of them can touch.
It’s a .env file. Not a product, not a subscription, not a plugin. Just key-value pairs sitting at ~/.env with permissions set to 600.
The reason it works is that it carries two credentials per site rather than one. An application password opens the REST API, which is the same door every WordPress MCP server knocks on. An SSH key opens the shell, which is where WP-CLI, the database, the file system, cron, and the logs live. Most tooling gives you the first and stops. The second is where the interesting work happens.
This is also the most dangerous file I own, and I’m going to be blunt about what’s currently wrong with mine.
What’s actually in it
The ~/.env setup | Count |
|---|---|
| WordPress sites managed this way | 18 |
| Keys per site block | 10 |
| Third-party service APIs | ~20 |
| File permissions | -rw------- (600) |
| Values with unquoted spaces, before I fixed them | ⚠️ 14 |
Every site follows the same shape, prefixed by a two or three letter site code. Same ten keys, every time, so the pattern is predictable enough that a tool can guess it:
_WORDPRESS_URL,_USERNAME,_APPLICATION_PASSWORDfor the REST channel_SSH_COMMAND,_FOLDER,_WPCLI_ENABLEDfor the shell channel_THEME,_DROPINS_DIRfor the things I edit most often
The prefix is the whole trick. GA_, GL_, AI_, and so on. Tell an assistant “publish this to GA” and it knows which three keys to read. Add another site and you add a block, not a config file, not an integration, not a seat on somebody’s plan.
Why two channels instead of one
The REST API is generous and it has a ceiling. Here’s where the ceiling sits, using jobs I ran this week:
| Job | REST | SSH + WP-CLI |
|---|---|---|
| Create, update, publish posts | ✅ | ✅ |
| Upload media, set alt text | ✅ | ✅ |
| Read and write taxonomy | ✅ | ✅ |
| Plugin meta not registered for REST | ⚠️ Only if the plugin exposes a route | ✅ wp post meta get |
| Query the database directly | ❌ | ✅ |
| Bulk-edit 500 posts | ⚠️ 500 HTTP calls | ✅ One command |
| Flush object cache | ❌ | ✅ |
| Read error logs | ❌ | ✅ |
| Inspect or repair files | ❌ | ✅ |
| Install, activate, roll back plugins | ⚠️ Limited | ✅ |
| Works when the site is white-screening | ❌ | ✅ |
That last row is the one people underestimate. When a site is down, the REST API is down with it. SSH is how you find out why.
The two channels also check each other. Yesterday I wrote SEO metadata through a REST route and then read the same field back over WP-CLI to confirm it landed. Two independent paths agreeing is a much stronger verification than one path reporting success to itself.
🔑 REST is for content. SSH is for everything content sits on top of. You want both, and one file can carry both.
What a WordPress MCP server actually is
Strip the packaging and a WordPress MCP server is a translator. It converts “publish this post” into an authenticated HTTP request against your REST API, using an application password you generated. That’s it. The intelligence is in the model, the access is in your credential, and the MCP is the adaptor in between.
Which means the interesting number isn’t what an MCP costs. It’s how much API surface your site already exposes. I counted the REST namespaces registered on one of my sites:
| REST namespaces on one WordPress site | Count |
|---|---|
| Total registered namespaces | 38 |
WordPress core (wp/v2, block editor, site health, abilities) | 4 |
| One SEO plugin alone | 8 |
| Commerce, forms, CRM, performance, link management | 12 |
| Everything else from installed plugins | 14 |
Thirty-eight namespaces, and one application password reaches all of them. Every plugin you install quietly widens that surface. A paid connector doesn’t add capability there. It adds convenience, and convenience is worth paying for right up until you find out what it can’t reach.
I hit exactly that ceiling this week. A plugin’s SEO fields aren’t registered for wp/v2, so a generic tool simply cannot see them. The fix was a plugin-specific REST route for the write and WP-CLI for the read-back. No connector was going to work that out for me. The credentials did.
When paying for a connector is the right call
None of this makes paid tooling a scam, and I want to be fair about where it earns its money. If you are not comfortable in a terminal, a hosted connector is a genuinely better answer than a credentials file you do not fully understand, because the failure mode of half-understood access is worse than the cost of a subscription. If a team needs shared, audited access, you need something with an audit log. And if a vendor maintains an integration against a moving API so you do not have to, that maintenance has real value.
What you should not do is pay for a connector believing it gives you access you would not otherwise have. It does not. It gives you a smoother path to access you already own, and it stops at the REST API’s edge. Know which of those two things you are buying.
One task, both channels
Here’s what that looks like in practice, stripped to its bones. Write through the API, verify through the shell:
# 1. Write, using the REST credentials
curl -u "$EX_USERNAME:$EX_APPLICATION_PASSWORD" \
-X POST "$EX_WORDPRESS_URL/wp-json/some-plugin/v1/updateMeta" \
-H "Content-Type: application/json" \
-d '{"objectID":1234,"meta":{"focus_keyword":"example phrase"}}'
# 2. Verify, using the shell credentials, through a completely different path
eval "$EX_SSH_COMMAND 'cd $EX_FOLDER && wp post meta get 1234 focus_keyword'"
# -> example phraseTwo credentials, two protocols, two answers that have to agree. If the write had silently failed, or the CDN had served me a stale read, the second command would have told me. A single-channel tool cannot run that check, because it only knows one way to ask the question.
A sample .env you can copy
Every value below is fictional. Copy the shape, not the contents. I’ve written it the way I now think it should be written rather than the way mine currently reads, and the gap between those two is the subject of a later section.
# ~/.env chmod 600 never committed, never pasted into a chat window
# ---------------------------------------------------------------
# SITE 1: example.com prefix: EX_
# ---------------------------------------------------------------
EX_WORDPRESS_URL="https://example.com"
EX_USERNAME="ai-agent"
# Application password, not your login password. Spaces are real: keep the quotes.
EX_APPLICATION_PASSWORD="abcd EFGH 1234 ijkl MNOP 5678"
# Key auth only. No password anywhere in this block.
EX_SSH_COMMAND="ssh -i ~/.ssh/example_agent -o IdentitiesOnly=yes -p 22 exampleuser@example.com"
EX_FOLDER="/home/exampleuser/public_html"
EX_WPCLI_ENABLED="true"
EX_THEME="my-child-theme"
# ---------------------------------------------------------------
# SITE 2: shop.example.net prefix: SH_
# ---------------------------------------------------------------
SH_WORDPRESS_URL="https://shop.example.net"
SH_USERNAME="ai-agent"
SH_APPLICATION_PASSWORD="qrst UVWX 9012 yzab CDEF 3456"
SH_SSH_COMMAND="ssh -i ~/.ssh/shop_agent -o IdentitiesOnly=yes -p 2222 shopuser@shop.example.net"
SH_FOLDER="/var/www/shop"
SH_WPCLI_ENABLED="true"
# Repeat the block per site. Same ten keys, new prefix.
# ---------------------------------------------------------------
# SERVICES: scoped tokens only
# ---------------------------------------------------------------
# Scoped API token limited to the zones and permissions you need.
# NOT the Global API Key, which cannot be scoped and cannot be limited.
CLOUDFLARE_API_TOKEN="fictional_scoped_token_value"
CLOUDFLARE_ZONE_ID="0000000000000000000000000000ffff"
OPENAI_API_KEY="sk-proj-fictional"
PSI_API_KEY="fictional-pagespeed-key"
# ---------------------------------------------------------------
# DELIBERATELY NOT IN THIS FILE
# ---------------------------------------------------------------
# - root SSH passwords
# - database root passwords
# - Cloudflare Global API Key
# - anything you cannot revoke from a web UI in under a minute
Quote every value. That is not a style preference, and the next section explains why it cost me an hour.
How to hand this to an AI tool
One rule covers most of it. Point the tool at the file. Never paste the file into a chat window.
A pasted credential lands in a conversation log, possibly in a training corpus, and definitely in your scrollback where it will sit for months. A credential read from disk at the moment it’s needed exists in memory for the length of one command. Every serious coding assistant can read local files. Let them do that instead.
Each tool has a project-level instructions file that gets loaded automatically at the start of a session. That’s where the pointer goes, and it’s the same one-liner in all three:
| Tool | Where the instruction goes |
|---|---|
| Claude Code | CLAUDE.md in the project root, or ~/.claude/CLAUDE.md globally |
| Codex | AGENTS.md in the project root |
| GitHub Copilot | .github/copilot-instructions.md |
The line itself is deliberately boring:
Credentials live in ~/.env. Load them at run time, never hardcode them.
Site prefixes: EX_ = example.com, SH_ = shop.example.net.
Each site block has _WORDPRESS_URL, _USERNAME, _APPLICATION_PASSWORD
for REST, and _SSH_COMMAND, _FOLDER for shell work.
Never echo a credential value into the transcript.That last line matters more than it looks. A tool debugging a failed request will happily print the header it sent, and now your application password is in the log after all. Say so explicitly once and you mostly avoid it.
The prompt then never contains a secret. You ask for the outcome, the assistant works out which keys it needs. “Push this draft to GA” resolves to three keys. “Flush the object cache on GL” resolves to a different two. You are naming sites, not credentials.
Three habits worth adopting alongside it. Keep the file outside the repository, at ~/.env rather than in the project, so a stray git add -A can never reach it. Read it with a real parser rather than shell sourcing, because Python’s dotenv handling copes with spaces that a shell silently mangles. And check the transcript occasionally, especially after a session where something failed and the tool started dumping request details to work out why.
Five traps, four of which I’ve fallen into
1. Unquoted values silently truncate. This is the expensive one. My SSH command is stored like this:
SSH_COMMAND=ssh -i ~/.ssh/agent_key -o IdentitiesOnly=yes -p 22 user@hostSource that file in a shell and the variable equals ssh. Everything after the first space is gone. No error, no warning. An agent reading it sees an empty-looking command, falls back to guessing root@host, and gets “Permission denied (publickey)” because the key authorises the site user, not root.
I watched exactly that happen this week and concluded SSH was broken. It wasn’t. The correct command was sitting in the file the whole time. 14 values in mine had this defect, including every application password, because WordPress generates those with spaces in them.
The fix is one character at each end. Quote every value. If you’d rather not rewrite the file, read the raw line instead of sourcing it: grep '^GA_SSH_COMMAND=' ~/.env | sed 's/^[^=]*=//'.
2. Root is not the user your key authorises. Managed hosts give each site its own shell user. Your key is on that user’s account, not root’s. Store the full working command rather than the pieces, so nothing has to be reassembled by guesswork.
3. Your CDN may be caching authenticated API reads. I write a change over REST, read it straight back, and get the previous value, because the edge served a cached response to an authenticated GET. Clearing the object cache doesn’t help; that’s a different cache. Add a throwaway query parameter to every verification read and the problem disappears.
4. Notes about your server go stale faster than you think. Three weeks ago WP-CLI on my box needed an explicit PHP binary because the default CLI PHP was missing the MySQL extension, so every bare wp command died. I checked again while writing this. It works now. Nobody told me; the host updated something. Re-test your workarounds instead of carrying them forever.
5. Success is not delivery. A 200 response means the request was accepted, not that the thing you wanted happened. Verify through the other channel. That’s the practical argument for holding both credentials rather than one.
Staying protected, and where mine currently fails
Here’s the part that justifies the whole approach, and the part where I have to mark my own homework honestly.
Application passwords are not passwords. That distinction is doing all the work. A WordPress application password is issued per application, revocable on its own from your profile page, shows you when it was last used, and never exposes your login. If one leaks you kill that row and nothing else changes. Someone holding it cannot log into wp-admin with it.
A root password is the opposite of all four of those things. And I have root passwords in my file.
| Credential in my file | Revocable alone? | Scopable? | Usage visible? | Verdict |
|---|---|---|---|---|
| WordPress application password | ✅ | ✅ Per user role | ✅ Last-used date | 🥇 Keep |
| SSH key, site user | ✅ Remove from authorized_keys | ✅ Per user | ⚠️ Via auth log | 🥇 Keep |
| Scoped service API token | ✅ | ✅ | ✅ Usually | 🥇 Keep |
ROOT_PASSWORD | ❌ Changing it breaks everything | ❌ | ❌ | ❌ Remove |
MYSQL_ROOT_PASSWORD | ❌ | ❌ | ❌ | ❌ Remove |
| Cloudflare Global API Key | ⚠️ Rotating breaks every integration | ❌ All permissions, all zones | ❌ | ❌ Replace with a scoped token |
Three rows of that table are indefensible and they’re all mine. The root passwords buy me nothing, because my SSH already authenticates with a key as the site user, and that’s the account WP-CLI needs. They sit there because I pasted them in during a migration and never took them out. The Cloudflare Global API Key is worse: Cloudflare’s own documentation tells you to use scoped API tokens instead, because the Global key carries every permission on every zone and rotating it breaks every integration at once.
The rest of the checklist:
- Give the agent its own WordPress user. Editor is enough for content work. Administrator is not required for publishing, and a compromised Editor cannot install a plugin.
- One application password per tool. Named for the tool. That’s the whole point of the feature, and it’s how you revoke surgically instead of resetting everything.
- Keys, never passwords, for SSH. Set
PasswordAuthentication noand be done. - chmod 600, and check it. A world-readable credential file on a shared machine is a bad afternoon.
- Never commit it.
.envin.gitignorebefore the first commit, not after. Git remembers. - Practise the revocation drill. Revoke an application password, watch the tool fail, issue a new one. If you’ve never done it, you don’t have a recovery plan, you have a hope.
One more, learned the embarrassing way. When I shared a screenshot of my own file publicly, I blurred the values and thought that was enough. It wasn’t. The image still showed my server’s IP address, four shell usernames, two WordPress usernames, and the exact filename of my SSH key. Blurring secrets doesn’t help if the structure around them is a map. Redact the hostnames and usernames too.
“Shouldn’t a good .env have no secrets in it?”
When I posted my file on X, David Schargel asked exactly that, and pointed at Doppler and Infisical as the better answer for most use cases.
He’s right, and the qualifier matters. A secrets manager gives you central rotation, per-environment scoping, audit logs of who read what, and no plaintext at rest. If you have teammates, a CI pipeline, or compliance requirements, use one. There’s no argument here.
For one operator on one laptop managing their own sites, the calculus changes. A secrets manager is another account, another dependency, another thing to be logged out of at the moment a client site goes down. It protects against threats a solo operator mostly doesn’t face: a leaking teammate, a compromised build runner, an auditor asking who read the production key in March.
The threat I actually face is my own laptop being compromised. A secrets manager doesn’t save me there. If an attacker has my machine, they have my session tokens too, and they’ll just ask Doppler nicely.
🔑 The line: the moment a second person needs these credentials, or a CI job does, move to a secrets manager. Until then, a 600-permission file with only revocable credentials in it is a proportionate answer, and the emphasis belongs on revocable.
Which is precisely why the root passwords have to come out of mine. The objection isn’t really “don’t use a file.” It’s “don’t put unrevocable secrets anywhere.” On that, Schargel and I completely agree, and my file currently loses the argument.
Get the skills pack I use with these credentials
The credentials open the door. What walks through it is a set of skill files that tell the assistant how my sites are actually built, so it doesn’t have to rediscover the conventions every session. I’ve packaged 25 of them.
25 distinctive Claude and Codex skills in one ZIP, with installation guidance, package notes, placeholder references, and SHA-256 checksums. Enter your details and I’ll email the secure download link. The newsletter checkbox is optional and can be cleared if you only want the pack.
Frequently asked questions
Is it safe to give an AI tool my WordPress credentials?
It is as safe as the credentials you give it. A WordPress application password is issued per application, revocable on its own, shows a last-used date, and cannot be used to log into wp-admin. That is a very different risk from handing over your account password. Give the agent its own user with an Editor role, issue it one named application password, and you can revoke that access in about fifteen seconds without touching anything else.
Do I need a paid MCP server to manage WordPress with AI?
No. A WordPress MCP server translates instructions into authenticated requests against your REST API using an application password you generate yourself. The access comes from the credential, not the connector. A paid connector buys convenience and support, which is a fair thing to pay for, but it does not add capability you do not already own. It also cannot reach anything outside the REST API, which is why an SSH key matters as much as the application password.
What can SSH do that the WordPress REST API cannot?
Direct database queries, bulk operations in a single command instead of hundreds of HTTP calls, object cache flushing, reading error logs, inspecting and repairing files, plugin rollbacks, and cron inspection. Most importantly it still works when the site is down. If WordPress is white-screening, the REST API is unavailable too, and SSH is how you find out why.
Should I use a secrets manager like Doppler or Infisical instead?
If more than one person needs the credentials, or a CI pipeline does, or you have compliance requirements, yes. Central rotation, per-environment scoping, and audit logs are real advantages a flat file cannot match. For a single operator managing their own sites on one machine, a file with 600 permissions holding only revocable credentials is proportionate. The threat a solo operator faces is laptop compromise, and a secrets manager does not protect against that, because the attacker inherits your session too.
Why do my .env values break when I load them?
Almost always unquoted values containing spaces. Shell sourcing truncates the value at the first space with no error, so an SSH command becomes just ssh and a WordPress application password loses everything after the first block. Wrap every value in double quotes. If you would rather not edit the file, read the raw line with grep and sed instead of sourcing it, or use a proper dotenv parser, which handles spaces correctly.
What should never go in a .env file?
Anything you cannot revoke from a web interface in under a minute. That rules out root SSH passwords, database root passwords, and provider keys that carry every permission on every resource, such as Cloudflare’s Global API Key rather than a scoped API token. If revoking a credential would break every integration you own at once, it does not belong in a file an automated tool reads.
How do I share a .env file safely if I want to show someone?
Do not share the real one. Publish a template with fictional values instead. Blurring the secrets in a screenshot is not enough, because the structure around them is still useful to an attacker: hostnames, IP addresses, shell usernames, WordPress usernames, and SSH key filenames all survive the blur. Redact those too, or rewrite the file with fake values before it leaves your machine.
The bottom line
Eighteen sites, two channels, no subscription. The file isn’t clever. What makes it work is that every credential in it is one you can destroy from a web page in under a minute, and that it carries the shell as well as the API.
Mine doesn’t fully meet that standard today. Yours can, starting from the template above, which is the version I’m rewriting mine to match.