A self-hosted WordPress install is not private by default. A stock install of WordPress 6.x on Docker reaches out to api.wordpress.org on a schedule, loads visitor avatars from Gravatar, and the moment you add Jetpack, Akismet, Elementor or a theme that bundles Google Fonts remotely, your visitors' IP addresses start landing in logs you do not control. The fix is mechanical and takes an afternoon: a three-container compose stack, roughly a dozen constants in wp-config.php, a handful of remove_action calls in a must-use plugin, locally hosted fonts, and a network capture to prove the page now talks to exactly one domain.
TL;DR
- The two person startup running its own marketing site (a founder and a designer, no sysadmin): Self-host WordPress on a small VPS or a Personal Cloud Server and spend the afternoon on the de-telemetry pass, because the alternative is paying a hosted builder monthly and still handing visitor data to its CDN.
- The agency shipping client sites under GDPR scrutiny (a two person studio with EU clients): Do the full pass and keep it as a reusable mu-plugin, because "we removed Google Analytics" is not a defensible answer when the theme still pulls fonts.google.com on every page load.
- The homelab tinkerer running WordPress behind Tailscale or a reverse proxy: Strip the outbound calls anyway, because an internal-only site that still phones home leaks the fact that your instance exists along with your server's IP.
-
The developer who wants automatic updates and plugin installs from the dashboard: Use the partial profile, leave
DISALLOW_FILE_MODSoff and block only the avatar, font and analytics calls, because full lockdown trades convenience for a monthly manual update ritual. -
The site owner with an existing WordPress install on shared hosting: Migrate to Docker first, then strip, because you cannot capture outbound traffic or edit
php.inion a host that gives you cPanel and nothing underneath it. - The reader who only needs a brochure site and no comments, no forms, no accounts: Consider a static generator instead, because the cheapest way to leak nothing is to ship HTML with no PHP runtime at all.
The central tradeoff: every outbound call you cut removes a convenience that WordPress built on top of it, so you are trading dashboard update notices, spam filtering and free avatars for a page that touches your domain and nothing else.
Table of contents
- What does a stock WordPress install actually send to third parties?
- Which Docker setup do you need before you can audit anything?
- How do you capture every outbound request your site makes?
- What exactly does Jetpack send, and what breaks when you remove it?
- How do you stop Gravatar leaking your commenters' email hashes?
- How do you self-host Google Fonts without breaking your theme?
- Should you block api.wordpress.org, and what do you lose?
- Which plugins phone home silently, and how do you catch them?
- How do you replace Google Analytics with something you own?
- What cookies does WordPress set, and which ones actually need consent?
What does a stock WordPress install actually send to third parties?
Download WordPress 6.x, run it with nothing added, and the install is already talking to servers outside your control. Most of it is core behaviour, not plugins, and none of it is announced in the admin UI.
- Update checks to api.wordpress.org: Core, themes and plugins each poll separate endpoints on a twice daily schedule through WP-Cron, and the core request sends your site URL, PHP version, MySQL version, locale and installed plugin list as query parameters.
-
Gravatar avatar requests to secure.gravatar.com: Every comment and every author byline renders an
<img>pointing at an MD5 hash of the user's email address, so your visitor's browser, not your server, makes that request and exposes their IP to Automattic. -
Browse Happy and Serve Happy notices: The dashboard calls out to check whether the visiting browser or your PHP version is considered outdated, which fires from
wp-adminrather than the front end. -
Bundled remote fonts in the active theme: Twenty Twenty-Four ships fonts locally, but a large share of commercial themes still enqueue
fonts.googleapis.cominfunctions.php, which means a font request on every single page view. - oEmbed discovery for pasted links: Paste a YouTube or X URL into the editor and WordPress fetches metadata from that provider at save time, then embeds an iframe that loads on render.
Where you run the stack determines whether you can see any of this. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. A self-managed VPS, a home server, a NAS or Yundera all give you container level network visibility; shared cPanel hosting does not.
Which Docker setup do you need before you can audit anything?
You cannot see what you cannot intercept. Shared hosting hands you a dashboard and hides the network layer, so the first step is a stack where every container is yours and docker compose logs is available.
-
Three services, not one: Run
wordpress:6-php8.3-fpmfor PHP,mariadb:11for the database andnginx:alpineas the front end, defined in a singledocker-compose.yml. The all-in-onewordpress:apacheimage works too, but splitting nginx out lets you log and block outbound traffic at one chokepoint later. -
A bind mount, not an anonymous volume: Map
./wp-content:/var/www/html/wp-contentso you can grep a theme'sfunctions.phpforfonts.googleapis.comfrom the host without entering the container. -
A WP-CLI sidecar: Add a
wordpress:cliservice sharing the same volume. Commands likewp plugin list --format=csvandwp cron event listbecome one-liners, and you will use both when you hunt for scheduled phone-homes. - A dedicated bridge network: Put the three services on their own network rather than the default bridge. This becomes the boundary you watch, and it keeps MariaDB off any published port, so only port 443 reaches the internet.
-
Real DNS and TLS before you test: Font fallbacks, oEmbed and update checks behave differently over plain HTTP, so audit the site at its final HTTPS hostname, not at
localhost:8080.
Any of a self-managed VPS, a home server, a NAS or Yundera gives you that container level control, and Yundera installs the stack from an app store rather than from a compose file you write yourself. What matters for the audit is only that you own the host and can reach the Docker socket.
How do you capture every outbound request your site makes?
Two different leaks need two different instruments. Server side calls come from PHP via the WP HTTP API and never appear in your browser. Front end calls come from the visitor's browser and never appear in your server logs. Audit both, or you will declare victory while Gravatar is still loading.
| Method | What it catches | What it misses |
|---|---|---|
| Browser DevTools, Network tab, hard reload | Every font, avatar, iframe, script and beacon the page loads, with the full third party domain list | All PHP side requests, including update checks and oEmbed fetches |
pre_http_request filter in a mu-plugin, logging to error_log
|
Every WP HTTP API call with URL, method and calling plugin | Direct curl_exec or raw socket calls that bypass the API |
tcpdump -i any -n 'port 53' inside the container |
DNS lookups for anything the stack resolves, including bypasses | Nothing on the wire, but it shows domains without payloads |
| nginx or firewall egress logs at the host | Every connection the container actually opens, by destination IP | Domain names, since you see IPs and must reverse them |
Start with the filter. Drop a file in wp-content/mu-plugins/audit.php that hooks pre_http_request, writes the URL and debug_backtrace() to the PHP log, and returns null so nothing is blocked. Then run wp cron event run --due-now to force the scheduled checks instead of waiting 12 hours. Finally load the front page in a private window with cache disabled and count the distinct domains in DevTools. On a stock install with one commercial theme you will typically see 3 to 6 external domains. Write that number down. It is your baseline.
What exactly does Jetpack send, and what breaks when you remove it?
Jetpack is not a plugin so much as a client for WordPress.com. Activating it requires connecting your site to an Automattic account, and from that point several of its features work by proxying through Automattic infrastructure rather than running on your server. That is the design, not a flaw, and it is the reason a privacy clean install cannot keep it.
- Stats and the activity log: Page views are recorded by a pixel served from Automattic, so visitor IP and user agent are processed off your server. Replace it with a self-hosted analytics tool and you lose the dashboard widget, nothing else.
-
Photon and the CDN for images: Your uploads are fetched by Automattic, cached, and served to visitors from their edge. Turn it off and images come from your own
wp-content/uploads, which means your server now carries that bandwidth. - Related Posts and search: Both are computed from an index Automattic holds of your content. Removing them costs real functionality, and the local replacements are slower on sites past a few thousand posts.
- Akismet spam filtering: Comment text, author name, email and IP are sent to Akismet for scoring. The honest replacement is closing comments, moving them to a self-hosted system, or accepting manual moderation.
-
Downtime monitoring and backups: These depend on an external service by definition. Swap in your own uptime check and a
mysqldumpcron.
Remove it cleanly with wp plugin deactivate jetpack && wp plugin delete jetpack, then check wp option list --search='jetpack*' because the connection tokens and ten or more options survive deletion. Disconnect the site from WordPress.com before deleting, otherwise the stale connection lingers on their side.
How do you stop Gravatar leaking your commenters' email hashes?
A Gravatar URL is not anonymous. WordPress hashes the lowercased email address and puts that hash in the image path, and because email addresses come from a small guessable space, anyone holding the hash can confirm a specific address by hashing it themselves. The request also carries the visitor's IP and the referring page, so Automattic sees who read which article, not just who commented.
-
Turn avatars off globally: Settings, then Discussion, then uncheck Show Avatars. From the CLI that is
wp option update show_avatars 0. This kills the request on the front end and in the comments list, and it is the only change that removes the hash entirely rather than hiding it. -
Do not rely on the default avatar setting: Choosing Mystery Person or Blank still generates a
secure.gravatar.comURL with thed=parameter, so the lookup happens anyway. Only the Gravatar logo option behaves differently, and it is still a remote call. -
Serve local avatars if you need faces: Simple Local Avatars stores an uploaded image per user in your own media library and short circuits
get_avatar. Useful on a multi author blog where bylines matter. -
Filter it in code for belt and braces: In your mu-plugin, add
add_filter('pre_get_avatar', ...)returning an empty string, oradd_filter('option_show_avatars', '__return_zero'). A theme or plugin that calls Gravatar directly bypasses the setting, and the filter catches most of those. -
Check the admin side too: The user list, the at a glance widget and comment moderation screens all render avatars, so confirm with DevTools on
/wp-admin/users.phpafter the change.
Reload the front page and the external domain count from your baseline should drop by one.
How do you self-host Google Fonts without breaking your theme?
Remote font loading is the leak most people miss, because the page looks identical whether the font comes from your domain or from Google. A German court has already ruled that passing visitor IP addresses to Google this way without consent is unlawful, so this is not a theoretical concern for an EU facing site.
-
Find the enqueue first: Run
grep -ri "fonts.googleapis\|fonts.gstatic" wp-content/on the host. Expect hits in the theme'sfunctions.php, intheme.jsonunderfontFace, and inside page builders like Elementor or Astra, which often expose a Load Fonts Locally toggle in their own settings. - Download only what you use: A typical theme requests 2 font families at 2 weights each. Pull the woff2 files, latin subset only, and you end up with 4 to 8 files of a few tens of kilobytes rather than a stylesheet that resolves to a second domain.
-
Declare them yourself: Put the files under
wp-content/themes/<child>/fonts/, write@font-faceblocks withfont-display: swap, and keep the exact samefont-familynames the theme uses in its CSS. Matching the names is what stops the layout shifting. -
Dequeue the remote handle: In your mu-plugin call
wp_dequeue_style()andwp_deregister_style()against the theme's font handle, then enqueue your local stylesheet with a later priority. OMGF automates the same job if you prefer a plugin. -
Preload the two critical faces: Add
<link rel="preload" as="font" crossorigin>for the body and heading weights only. Preloading all eight costs more than it saves.
Serving those files is your server's job now, which is true on a self-managed VPS, a home server, a NAS or Yundera alike. Verify with DevTools that fonts.gstatic.com no longer appears.
Should you block api.wordpress.org, and what do you lose?
This is the one call worth keeping. The update check tells your site that WordPress 6.x shipped a security release, and a site that never learns about security releases is a worse privacy outcome than one that pings a known endpoint twice a day. The useful move is to narrow the allowlist to that single destination and block everything else by default.
| Level | How you do it | What it costs you |
|---|---|---|
| Allowlist only wordpress.org |
define('WP_HTTP_BLOCK_EXTERNAL', true); plus define('WP_ACCESSIBLE_HOSTS', 'api.wordpress.org,*.wordpress.org'); in wp-config.php
|
Nothing functional, and any plugin that quietly calls a vendor endpoint now fails silently, which is how you find it |
| Drop the cosmetic pings |
remove_action('admin_init', 'wp_check_browser_version') and the Serve Happy hook in your mu-plugin |
Two dashboard notices you were ignoring |
| Disable auto updates, keep checks | define('AUTOMATIC_UPDATER_DISABLED', true); |
You now apply every release by hand, so budget 15 to 30 minutes per month |
| Full lockdown |
define('DISALLOW_FILE_MODS', true); plus unscheduling wp_version_check
|
No update notices, no plugin installs from the dashboard, and you must track releases yourself |
Pick the first two rows and stop. The data in a core update request is your site URL, PHP and database versions and a plugin list, which is modest compared with losing patch awareness. If you do go to full lockdown, pair it with a rebuild pipeline: pin the image tag in docker-compose.yml, update the tag deliberately, and run wp core version after every rebuild to confirm what you are actually serving.
Which plugins phone home silently, and how do you catch them?
Assume every plugin with a paid tier talks to its vendor. Licence validation is legitimate, but the same request often carries an install fingerprint, and some plugins bundle a telemetry SDK that reports your active plugin list and admin email unless you decline a prompt you clicked past during setup.
- Licence and activation checks: Premium page builders, form plugins and SEO tools validate a key against the vendor's API, usually on a daily schedule. These cannot be removed without disabling the licence, so the honest options are to accept the call, allowlist that one host, or use a free alternative.
-
Opt in telemetry frameworks: Freemius is the common one, and it appears inside dozens of freemium plugins rather than as a plugin of its own. Grep for it directly with
grep -rl "freemius\|Freemius" wp-content/plugins/, then check for awp_fs_*prefixed row in the options table. - Remote asset loading: Some plugins pull icon sets, map tiles or script libraries from a CDN at render time rather than bundling them. DevTools catches these because they show up as front end requests from a domain you never configured.
- Analytics and consent plugins: A cookie banner plugin that fetches its own config from the vendor is leaking the exact visitors it claims to protect. Check these first, since they load before anything else on the page.
- Email and form delivery: A contact form that posts through a vendor relay instead of your SMTP server sends submission content off your server. Point it at your own SMTP host instead.
Your audit log from earlier makes this quick: run wp cron event run --due-now, then read the mu-plugin log and map each logged URL to the plugin that called it using the backtrace.
How do you replace Google Analytics with something you own?
GA4 is the single largest leak on a typical WordPress site, and it is also the easiest to remove because nothing on the site depends on it. The replacement question is really about how much infrastructure you want to run for the numbers you actually read.
| Option | What it adds to your stack | Tradeoff |
|---|---|---|
| Matomo, self-hosted | One PHP container plus a database, or the WP-Matomo plugin inside the existing install | Heaviest feature set, including goals and heatmaps, and the database grows fastest |
| Plausible Community Edition | Two more containers, PostgreSQL and ClickHouse | Cookieless by design and a very small script, but the most moving parts to maintain |
| Umami | One Node container plus PostgreSQL or MySQL | Light and simple, with a narrower report set than Matomo |
| GoAccess on nginx logs | Nothing in the browser at all | Zero JavaScript and zero consent question, but no distinction between a human and a crawler without tuning |
Four practical notes. Configure IP anonymisation before you record a single hit: Matomo can mask the last 2 bytes of each address, which keeps country level reporting while discarding the identifier. Serve the tracker from your own domain, not a vendor CDN, otherwise you have moved the leak rather than closed it. Keep the script local by enqueueing it in your mu-plugin with wp_enqueue_script so a theme update cannot reintroduce the Google tag. Finally, delete the old integration properly: wp plugin list --status=active will often reveal a Site Kit or GA plugin still present, and the measurement ID is frequently hardcoded in the theme's header template as well.
Server log analysis is the one option that survives an ad blocker.
What cookies does WordPress set, and which ones actually need consent?
A clean install sets fewer cookies than most people assume, and almost all of them fall under the strictly necessary exemption, which means no banner is required for them. The banner question is created by what you add, not by WordPress itself.
-
wordpress_test_cookie: Set on the login page to confirm the browser accepts cookies, and expires with the session. Strictly necessary, no consent needed, and it never reaches a logged out visitor browsing an article. -
wordpress_sec_andwordpress_logged_in_with a hash suffix: The authentication pair, scoped to the admin area and the site respectively. They last 2 days, or 14 days when the user ticks Remember Me. Necessary for a logged in session, so exempt. -
wp-settings-{user_id}andwp-settings-time-{user_id}: Store admin interface preferences such as editor state for 1 year. Only ever set for authenticated users, so again exempt, and irrelevant on a site with two staff accounts and no public registration. -
comment_author_,comment_author_email_andcomment_author_url_: Set when someone comments and retained for roughly a year. Since WordPress 4.9.6 the comment form carries a tick box that gates them, andcomment_cookies_consentrecords the choice. Leave that box unticked by default. -
WooCommerce session and cart cookies:
woocommerce_cart_hash,woocommerce_items_in_cartandwp_woocommerce_session_appear the moment you install the plugin. Cart function is necessary; the marketing extensions bolted onto it usually are not.
Verify the real list rather than trusting a policy template. Open DevTools, Application, Cookies, then load the front page in a private window as an anonymous visitor. If the only entry is a cookieless analytics flag or nothing at all, you do not need a consent banner for a reading visitor.
Top comments (0)