<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: hello world_leo</title>
    <description>The latest articles on DEV Community by hello world_leo (@leo_rio).</description>
    <link>https://dev.to/leo_rio</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3395052%2Ffd80024d-a2a7-43d5-aebb-7b76f5bf5044.png</url>
      <title>DEV Community: hello world_leo</title>
      <link>https://dev.to/leo_rio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leo_rio"/>
    <language>en</language>
    <item>
      <title>19 OOM kills in 9 days: diagnosing a shared-hosting WordPress before the rebuild</title>
      <dc:creator>hello world_leo</dc:creator>
      <pubDate>Sun, 06 Sep 2026 06:28:09 +0000</pubDate>
      <link>https://dev.to/leo_rio/19-oom-kills-in-9-days-diagnosing-a-shared-hosting-wordpress-before-the-rebuild-36pn</link>
      <guid>https://dev.to/leo_rio/19-oom-kills-in-9-days-diagnosing-a-shared-hosting-wordpress-before-the-rebuild-36pn</guid>
      <description>&lt;p&gt;Nineteen OOM kills in nine days. Ten WordPress apps on a 32GB shared box. One of them a client site that took a CPU spike on 14 July during a paid campaign burst, and pushed the whole tenant into the wall.&lt;/p&gt;

&lt;p&gt;This post is the diagnosis before the rebuild. What we actually found when we stopped guessing. Two layers of Cloudflare, one page cache plugin, one preloader being silently challenged, and a language subpath that was cold every time it mattered.&lt;/p&gt;

&lt;p&gt;I'm writing it partly for anyone who runs multi-tenant WordPress on Cloudways or similar, and partly as a reminder for future-me. There's a checklist at the end. Steal it.&lt;/p&gt;

&lt;p&gt;The client is anonymized throughout. Every number is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Traffic hits two Cloudflare layers before it reaches origin. Both are Cloudflare, but they're different zones on different accounts, and they own different things.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ visitor ]
    ↓
[ Upstream Cloudflare zone (managed by a third party) ]  ← DNS, SSL, HTML edge cache
    ↓
[ Cloudflare Enterprise add-on sold by Cloudways ]  ← WAF, bot, rate limit, AI crawler block
    ↓
[ Cloudways origin: nginx + PHP-FPM ]
    ↓
[ WordPress + WPML + Elementor + FlyingPress ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two Cloudflares isn't a mistake. The domain has been on Cloudflare via an upstream party since before the site moved to Cloudways. When Cloudways later offered a Cloudflare Enterprise add-on for its security stack, we kept both. We manage the Cloudways side. We don't own the upstream zone, which shapes what we can and can't do without a request going out. The trap is that both layers can cache HTML, and both can serve security challenges. If nobody writes down which layer does what, they fight.&lt;/p&gt;

&lt;p&gt;Our ownership split ended up like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upstream Cloudflare (third party)&lt;/td&gt;
&lt;td&gt;DNS, SSL, HTML edge cache, purge lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudways CF Enterprise add-on&lt;/td&gt;
&lt;td&gt;WAF, bot management, rate limiting, AI crawler blocking, ScrapeShield, Browser Integrity Check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FlyingPress&lt;/td&gt;
&lt;td&gt;Origin page cache, Cloudflare integration pointed at the upstream zone, purge rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudways "Edge Page Caching" (feature)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;OFF.&lt;/strong&gt; Turning it on would create a third HTML cache.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row matters more than it looks. Cloudways sells "Edge Page Caching" as a nice-sounding thing you can toggle in their panel. Enable it and you have three parties trying to cache HTML for the same URL: the upstream CF zone, the Cloudways CF zone, and Cloudways' own edge. Purges from the plugin will hit one of them and leave the other two stale. You'll debug ghosts for a week.&lt;/p&gt;

&lt;p&gt;Keep it off. FlyingPress owns the HTML cache. Full stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke on 14 July
&lt;/h2&gt;

&lt;p&gt;The client ran a paid campaign. Traffic pushed harder than usual, mostly at one language subpath because the campaign was regionally targeted. CPU on the shared Cloudways box climbed and stayed climbed. PHP-FPM saturated. Response times went from 400ms to multi-second. Origin bandwidth was normal, which ruled out a DDoS or a bot flood.&lt;/p&gt;

&lt;p&gt;I spent the first hour on the wrong hypotheses.&lt;/p&gt;

&lt;p&gt;Not the problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bot traffic. The Cloudways CF WAF logs were clean. Nothing unusual.&lt;/li&gt;
&lt;li&gt;Disk I/O. &lt;code&gt;iostat&lt;/code&gt; showed the array yawning.&lt;/li&gt;
&lt;li&gt;MySQL. Slow query log was quiet.&lt;/li&gt;
&lt;li&gt;A plugin gone rogue. &lt;code&gt;wp plugin list&lt;/code&gt; matched the last known-good state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real problem was compound, and every part of it made the next part worse:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The targeted subpath's FlyingPress cache was cold.&lt;/strong&gt; Not empty, but not warm at the pages the campaign was sending traffic to. Every ad click hit a URL that wasn't in cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The upstream CF zone was not caching HTML.&lt;/strong&gt; No Cache Rule, no Page Rule. So the edge added no protection. Every request went to origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The FlyingPress preloader was silently failing.&lt;/strong&gt; It crawls the sitemap and warms the origin cache. The Cloudways CF layer was serving it a Browser Integrity Check challenge. The preloader isn't a browser, so it never solved the challenge, so the pages never warmed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party oEmbed calls per page.&lt;/strong&gt; Several of the affected pages embed content from a large external social network. WordPress calls that oEmbed endpoint on render, synchronously, and blocks PHP until it answers. Under load, the provider was slow. PHP workers held.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WPML + Elementor render cost.&lt;/strong&gt; A cold page rendering through WPML routing and Elementor's builder is 100-200ms of PHP on a good day. Add oEmbed round-trips. Multiply by every worker being stuck.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cold cache × silent preloader × blocking third-party call × heavy render stack × shared tenancy. Any one of those alone is a shrug. All five together is a CPU wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two Cloudflare layers, again
&lt;/h2&gt;

&lt;p&gt;Because they own different concerns, and you don't want the security vendor also owning the cache purge lifecycle.&lt;/p&gt;

&lt;p&gt;The upstream CF zone handles DNS. It came with an HTML cache nobody was using until the incident. FlyingPress has a native integration that manages that cache: it holds the Zone ID and a scoped API token, and it purges the right URLs when content updates. Since the zone isn't ours, both values had to be requested from the party that owns it.&lt;/p&gt;

&lt;p&gt;The Cloudways Enterprise add-on gives us WAF rules, bot heuristics, rate limiting on abusive paths, and AI crawler blocking. That last one is worth its own post. The volume of scraper traffic from LLM crawlers has been non-trivial. We don't ask that layer to cache HTML. We ask it to reject the things we don't want to serve.&lt;/p&gt;

&lt;p&gt;The mistake would be enabling HTML caching on both. Or worse, enabling Cloudways' third "Edge Page Cache" toggle and having three caches with three independent lifetimes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caching path that works
&lt;/h2&gt;

&lt;p&gt;Once we understood the seams, the fix was mechanical. FlyingPress has a Cloudflare integration built in. Give it the right zone and the right token and it does the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token scope.&lt;/strong&gt; Don't over-scope this. If the zone belongs to a third party (as in our case), you're requesting the token from them. Ask for exactly this and nothing broader:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zone → Cache Purge → Purge&lt;/li&gt;
&lt;li&gt;Zone → Page Rules → Edit&lt;/li&gt;
&lt;li&gt;Zone → Zone Settings → Edit&lt;/li&gt;
&lt;li&gt;Zone → Cache Rules → Edit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. No account-level permissions. No workers. No DNS edit. If someone in support tries to hand you a global API key, hand it back and ask for a scoped token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup order.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Paste Zone ID and API token into FlyingPress → CDN → Cloudflare.&lt;/li&gt;
&lt;li&gt;Click Test Connection. Get green.&lt;/li&gt;
&lt;li&gt;Enable Cloudflare Page Caching.&lt;/li&gt;
&lt;li&gt;Save.&lt;/li&gt;
&lt;li&gt;Purge all cache once. This is the only time you do a full purge on purpose. Everything after this is targeted.&lt;/li&gt;
&lt;li&gt;Verify.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Verify with curl, twice.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://client.example/some-page/ &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cf-cache-status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First request should return &lt;code&gt;cf-cache-status: MISS&lt;/code&gt;. Second request, within a minute, should return &lt;code&gt;cf-cache-status: HIT&lt;/code&gt;. If the second request also returns MISS, the edge isn't caching and you need to check Cache Rules on the zone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edit-and-purge test.&lt;/strong&gt; Open a test page in the WordPress editor. Change one word. Update. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://client.example/test-page/ &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cf-cache-status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect MISS (FlyingPress purged the URL on update), then HIT on the next call. If you get HIT immediately after an edit, the purge isn't wired. Check the token scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editorial rules for the content team.&lt;/strong&gt; These sound small. They keep the hit rate up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After editing a page: &lt;strong&gt;Clear current page&lt;/strong&gt;, never Clear all cache.&lt;/li&gt;
&lt;li&gt;Don't purge multiple times in one editing session. One purge per finished edit.&lt;/li&gt;
&lt;li&gt;Don't cache logged-in users. FlyingPress has this setting. It should be off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "Clear all cache" button is a footgun. One editor with a habit of clicking it after every save can flatten a 700-page cache twice a day, and the preloader has to rebuild it every time, and you're back to hitting origin for everything until it's warm again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fixes killed
&lt;/h2&gt;

&lt;p&gt;Once the caching path was clean and the preloader was allowed through the Cloudways CF layer, the symptoms stopped stacking.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The preloader now runs at sitemap scale. 775 pages cached, matching the site's public URL count.&lt;/li&gt;
&lt;li&gt;The targeted subpath is warm before campaigns launch.&lt;/li&gt;
&lt;li&gt;No more silent challenge on the preloader. We added a targeted allow rule for the preloader's origin IP on the Cloudways CF layer.&lt;/li&gt;
&lt;li&gt;CPU under normal traffic returned to baseline. No campaign burst has hit the same wall since.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The oEmbed problem didn't go away. It got buried under caching. Pages that used to call the third-party endpoint on every render now call it on the render that populates the cache, and once. That's fine at current traffic. If we scale 10x it's back on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling nobody was tuning past
&lt;/h2&gt;

&lt;p&gt;Here's the part that reframed the whole project.&lt;/p&gt;

&lt;p&gt;Even with the caching path fixed, the box is still ten WordPress apps on 32GB of RAM. WordPress with Elementor and WPML has a fat baseline. 100 to 200MB of resident memory per PHP worker is normal. Each app's FPM pool holds workers, and each pool competes with nine other apps' pools for the same RAM.&lt;/p&gt;

&lt;p&gt;Nineteen OOM kills in nine days. Roughly two per day. That's not a tuning problem. That's a tenancy problem.&lt;/p&gt;

&lt;p&gt;I ran another week of measurements to confirm. PHP-FPM &lt;code&gt;pm.max_children&lt;/code&gt; was already conservative on each app. &lt;code&gt;opcache.memory_consumption&lt;/code&gt; was fine. There was no single-app memory leak. What was happening was traffic peaks on any one of the ten apps could push the shared box past its budget, and the kernel would kill whichever process it liked least.&lt;/p&gt;

&lt;p&gt;At that point, tuning has diminishing returns. The next lever is one of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade the Cloudways plan.&lt;/strong&gt; Bigger shared box, same ten apps. Buys headroom. Cheapest option. Reversible in an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consolidate.&lt;/strong&gt; Move some apps off. Reduces contention. Reversible, but touches other clients' sites, which means coordination cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the biggest app to dedicated.&lt;/strong&gt; OVH Advance-3 is on the shortlist. Reversible if we keep the Cloudways plan alive during transition for a week.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We're in the middle of that decision as I write this. Client hasn't picked yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules I set for myself before any migration
&lt;/h2&gt;

&lt;p&gt;Written down so future-me doesn't get clever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reversibility test.&lt;/strong&gt; Whatever I change, I need a path back to the previous state inside 24 hours. If I can't answer "how do I undo this by tomorrow", the plan isn't finished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One variable at a time.&lt;/strong&gt; If we move to dedicated, we don't also convert to Multisite in the same window. Two-variable moves are undebuggable when they break. Move first, convert later, or the other way around, never both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dual-run during migration.&lt;/strong&gt; Old and new live in parallel for at least a week. DNS TTL down to 300 before the cutover so we can bounce back. No burning the old server until the new one has survived a real traffic day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No infra work during editorial hours.&lt;/strong&gt; The content team is in a different timezone from mine. If I break something they're editing, they can't reach me for hours. Migration windows are theirs, off-hours, negotiated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're deferring on purpose
&lt;/h2&gt;

&lt;p&gt;Every one of these has been raised at some point. Every one gets a "not yet" and a reason.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Headless WordPress rewrite.&lt;/strong&gt; Six-month project. The current fixes bought us the runway to plan it properly instead of doing it under an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress Multisite conversion.&lt;/strong&gt; High risk. Doesn't fix the tenancy problem, just moves it. Not in the migration window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel or Filament rewrite.&lt;/strong&gt; Premature. Editors know WordPress. Retraining the content team is a bigger cost than the hosting bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancelling the Cloudways CF add-on.&lt;/strong&gt; Still doing useful WAF and bot work. Don't remove security infrastructure during a period of instability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Boring answer. Correct answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist for future-me (and you, if it helps)
&lt;/h2&gt;

&lt;p&gt;Save this. Run through it after every infra change, and before every campaign launch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] FlyingPress → Cloudflare integration shows Zone ID + scoped API token
[ ] Cloudways Edge Page Caching is OFF
[ ] curl cf-cache-status returns MISS then HIT on an anonymous URL
[ ] Edit a test page, save, verify purge (MISS on next curl, then HIT)
[ ] FlyingPress preloader completes; cached page count ≈ sitemap URL count
[ ] Preloader is not being challenged by the Cloudways CF layer
    (check firewall events for the preloader's source IP)
[ ] Logged-in user caching is OFF in FlyingPress
[ ] Editorial team briefed: "Clear current page", never "Clear all"
[ ] 48h monitoring after any change:
    - CPU peaks
    - PHP-FPM saturation (max_children hit count)
    - Origin request rate (should drop after caching is live)
    - p95 response time
[ ] If OOM count &amp;gt; 5 in 7 days → stop tuning, escalate to hardware decision
[ ] Before any migration:
    - Reversibility path in writing
    - One variable at a time
    - Dual-run for at least a week
    - Migration window agreed with editorial team
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The thing I keep learning
&lt;/h2&gt;

&lt;p&gt;Caching plugins get treated as install-and-forget. Set up FlyingPress, tick the boxes, move on. The interesting problems are never inside the plugin. They're at the seams. Which layer caches. Which layer purges. Which layer challenges. Whether the preloader looks like a browser to the security layer. Whether the token has the right scope.&lt;/p&gt;

&lt;p&gt;The 14 July spike wasn't a FlyingPress bug. It wasn't a Cloudflare bug either. It was a seams bug. Two Cloudflares, one plugin, and nobody's integration was pointed at the right zone. The fix was ten minutes of configuration once we knew what to configure. Getting to "what to configure" took two weeks.&lt;/p&gt;

&lt;p&gt;Boring headers. Expensive lessons. Write down which layer owns what before you need to know.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>devops</category>
      <category>cloudflare</category>
      <category>performance</category>
    </item>
    <item>
      <title>Verify That Your GitHub Actions Deployment Actually Landed on the Server</title>
      <dc:creator>hello world_leo</dc:creator>
      <pubDate>Thu, 28 May 2026 04:22:01 +0000</pubDate>
      <link>https://dev.to/leo_rio/verify-that-your-github-actions-deployment-actually-landed-on-the-server-2160</link>
      <guid>https://dev.to/leo_rio/verify-that-your-github-actions-deployment-actually-landed-on-the-server-2160</guid>
      <description>&lt;p&gt;I trusted GitHub Actions' green checkmark for months before I noticed it was lying to me. Not lying, exactly. It was reporting on whether &lt;code&gt;curl&lt;/code&gt; returned 200. Whether the actual code on the server had changed was a separate question, and one nothing in the pipeline was answering.&lt;/p&gt;

&lt;p&gt;Our deployment webhook, in outline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions → HTTPS POST → deploy.php on server → runs deploy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why webhooks, not SSH
&lt;/h2&gt;

&lt;p&gt;Hypernode (our host) IP-whitelists SSH access. GitHub Actions runs on rotating Azure IPs that never survive the whitelist. Port 443 has no such restriction, so a webhook it is. This is a common pattern on managed hosting.&lt;/p&gt;

&lt;p&gt;The workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Trigger deploy on server&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;response=$(curl -s -w "\n%{http_code}" \&lt;/span&gt;
        &lt;span class="s"&gt;-X POST https://www.yoursite.nl/deploy.php \&lt;/span&gt;
        &lt;span class="s"&gt;-H "X-Deploy-Token: ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \&lt;/span&gt;
        &lt;span class="s"&gt;--max-time 300)&lt;/span&gt;

      &lt;span class="s"&gt;code=$(echo "$response" | tail -n 1)&lt;/span&gt;
      &lt;span class="s"&gt;if [ "$code" != "200" ]; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "❌ Deployment failed (HTTP $code)"&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;
      &lt;span class="s"&gt;fi&lt;/span&gt;
      &lt;span class="s"&gt;echo "✅ Deployment successful"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;deploy.php&lt;/code&gt; runs &lt;code&gt;deploy.sh&lt;/code&gt; synchronously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"bash &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$script&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 2&amp;gt;&amp;amp;1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$exit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exit&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;exec()&lt;/code&gt; blocks until the script finishes. So GitHub gets an accurate exit code, right? Mostly. There's one case where the pipeline reports success while nothing on the server changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent lock skip
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;deploy.sh&lt;/code&gt; uses a lock file to prevent concurrent deploys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LOCK_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/mysite-deploy.lock"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;log &lt;span class="s2"&gt;"ERROR: Deployment already in progress. Aborting."&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s2"&gt;"rm -f &lt;/span&gt;&lt;span class="nv"&gt;$LOCK_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; EXIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normal case: a second push arrives while the first deploy is still running. The second &lt;code&gt;deploy.sh&lt;/code&gt; sees the lock, exits 1, &lt;code&gt;deploy.php&lt;/code&gt; returns 500, GitHub marks the workflow failed. Correct behavior.&lt;/p&gt;

&lt;p&gt;Broken case: the first deploy dies mid-way. Network blip on &lt;code&gt;git fetch&lt;/code&gt;, OOM kill, &lt;code&gt;curl&lt;/code&gt; times out on the composer install, whatever. The trap doesn't fire cleanly, the lock file stays. Every subsequent deploy silently exits 1 with the "already in progress" message. Depending on how your workflow parses the response, that failure may or may not surface loudly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Red flag:&lt;/strong&gt; a lock file left behind by a crashed process is the deployment equivalent of a hung phone line. Sounds like success until you check who's actually there. If your deploy pipeline uses a lock, it needs a way to distinguish "genuinely running" from "abandoned lock from last time."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Beyond the lock issue, the pipeline had a deeper hole. Even when a deploy succeeded, we had no proof of what commit actually landed. Verifying meant SSHing in and checking file timestamps, or grepping for a string we knew existed only in the latest commit. Not scalable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix part 1: return 423 when locked
&lt;/h2&gt;

&lt;p&gt;Detect the lock in &lt;code&gt;deploy.php&lt;/code&gt; before invoking &lt;code&gt;deploy.sh&lt;/code&gt; and return HTTP 423 Locked. It's the correct status code for "this resource is currently locked", and it lets the workflow tell the difference between a real deploy failure and a stale lock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$lockFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/tmp/mysite-deploy.lock'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lockFile&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;423&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LOCKED: Deployment already in progress. Try again shortly.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"bash &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$script&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 2&amp;gt;&amp;amp;1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$exit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exit&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handle 423 explicitly in the workflow so the CI log names the actual failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"423"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"⚠️  Deploy locked, previous deploy still running or lock is stale"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ Deployment failed (HTTP &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You could add lock-age detection so a lock file older than X minutes gets treated as stale and removed automatically. I chose to keep this manual, on the theory that a stale lock indicates something worth investigating before the next automated deploy tramples over whatever broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix part 2: write a version file
&lt;/h2&gt;

&lt;p&gt;At the end of &lt;code&gt;deploy.sh&lt;/code&gt;, after every step has completed, capture the deployed commit SHA and write it to a public file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Right after git reset --hard&lt;/span&gt;
&lt;span class="nv"&gt;DEPLOYED_COMMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse HEAD&lt;span class="si"&gt;)&lt;/span&gt;
log &lt;span class="s2"&gt;"Code updated (commit: &lt;/span&gt;&lt;span class="nv"&gt;$DEPLOYED_COMMIT&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;

&lt;span class="c"&gt;# ... composer install, yarn build, artisan migrate, etc.&lt;/span&gt;

&lt;span class="c"&gt;# At the very end, after every other step succeeded&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEPLOYED_COMMIT&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; public/version.txt
log &lt;span class="s2"&gt;"Version file written: &lt;/span&gt;&lt;span class="nv"&gt;$DEPLOYED_COMMIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order matters. Write this after &lt;code&gt;git clean&lt;/code&gt; (which would nuke a &lt;code&gt;version.txt&lt;/code&gt; that isn't gitignored) and after every other build step (so a partial deploy doesn't advertise a SHA that isn't fully live). Add &lt;code&gt;public/version.txt&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; so it never gets committed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix part 3: verify from the CI job
&lt;/h2&gt;

&lt;p&gt;After the webhook returns, poll &lt;code&gt;version.txt&lt;/code&gt; from GitHub Actions and confirm the SHA matches what we just pushed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify deployment landed&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;EXPECTED="${{ github.sha }}"&lt;/span&gt;
      &lt;span class="s"&gt;echo "Expected SHA: $EXPECTED"&lt;/span&gt;

      &lt;span class="s"&gt;for i in $(seq 1 20); do&lt;/span&gt;
          &lt;span class="s"&gt;sleep 15&lt;/span&gt;
          &lt;span class="s"&gt;DEPLOYED=$(curl -sf "https://www.yoursite.nl/version.txt" 2&amp;gt;/dev/null | awk '{print $1}')&lt;/span&gt;
          &lt;span class="s"&gt;echo "Attempt $i/20: server=${DEPLOYED:-none}"&lt;/span&gt;
          &lt;span class="s"&gt;if [ "$DEPLOYED" = "$EXPECTED" ]; then&lt;/span&gt;
              &lt;span class="s"&gt;echo "✅ Deploy confirmed on server: $DEPLOYED"&lt;/span&gt;
              &lt;span class="s"&gt;exit 0&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
      &lt;span class="s"&gt;done&lt;/span&gt;

      &lt;span class="s"&gt;echo "❌ Deploy verification FAILED after 5 minutes"&lt;/span&gt;
      &lt;span class="s"&gt;echo "Expected: $EXPECTED"&lt;/span&gt;
      &lt;span class="s"&gt;echo "Got:      ${DEPLOYED:-no response from server}"&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Polls every 15 seconds for up to 5 minutes. Since &lt;code&gt;deploy.php&lt;/code&gt; blocks synchronously on &lt;code&gt;exec()&lt;/code&gt;, by the time this step runs the deploy has already finished and the first poll almost always matches. The retry loop only matters if there's a CDN cache between GitHub Actions and the origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like now
&lt;/h2&gt;

&lt;p&gt;The GitHub Actions log after a successful deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Run EXPECTED="6f250fe1a2b9bb11ea826325a8a486b25279dfb1"
Waiting for deploy to complete on server...
Expected SHA: 6f250fe1a2b9bb11ea826325a8a486b25279dfb1
Attempt 1/20: server=6f250fe1a2b9bb11ea826325a8a486b25279dfb1
✅ Deploy confirmed on server: 6f250fe1a2b9bb11ea826325a8a486b25279dfb1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed on the first attempt. And the same file is readable from anywhere without credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.yoursite.nl/version.txt
6f250fe1a2b9bb11ea826325a8a486b25279dfb1 2026-05-28T03:41:30Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson learned:&lt;/strong&gt; the deploy pipeline should prove that it succeeded, not assume that a 200 means it did. &lt;code&gt;curl&lt;/code&gt; returning 200 says the webhook responded. It doesn't say the code changed. Two lines of &lt;code&gt;version.txt&lt;/code&gt; plus a poll loop is the difference between hoping and knowing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why not just &lt;code&gt;git log&lt;/code&gt; on the server
&lt;/h2&gt;

&lt;p&gt;You could SSH in and run &lt;code&gt;git log -1&lt;/code&gt;. That requires SSH access (blocked in our case), a separate monitoring job, or a human at a keyboard. The &lt;code&gt;version.txt&lt;/code&gt; approach works over plain HTTPS from anywhere, from any browser, with no credentials. Same information, easier to consume, and the CI checks it automatically as part of the deploy job.&lt;/p&gt;

&lt;p&gt;You could SSH in and run git log -1 — but that requires SSH access from CI (blocked for us), a separate monitoring job, or manual checks. The version.txt approach works over plain HTTPS with no&lt;br&gt;
credentials, from anywhere, including your browser.&lt;/p&gt;

&lt;p&gt;It also separates concerns: GitHub Actions verifies the outcome, not the process. Even if the internals of deploy.sh change, the verification contract stays the same "does the server report the right SHA?"&lt;/p&gt;






&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Summary

┌──────────────────────────────────────────────────┬───────────────────────────────────────┐
│                     Problem                      │                  Fix                  │
├──────────────────────────────────────────────────┼───────────────────────────────────────┤
│ No signal when deploy is locked                  │ deploy.php returns HTTP 423           │
├──────────────────────────────────────────────────┼───────────────────────────────────────┤
│ No proof of what commit landed                   │ deploy.sh writes public/version.txt   │
├──────────────────────────────────────────────────┼───────────────────────────────────────┤
│ GitHub shows success without server confirmation │ GitHub Actions polls and verifies SHA │
└──────────────────────────────────────────────────┴───────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three small changes. Zero new dependencies. Works with any stack that can serve a static file over HTTP.&lt;/p&gt;

</description>
      <category>github</category>
      <category>devops</category>
      <category>laravel</category>
      <category>deployment</category>
    </item>
    <item>
      <title>Building a Multi-Tenant Firebase Application: Environment Setup and Role-Based Access Control</title>
      <dc:creator>hello world_leo</dc:creator>
      <pubDate>Sat, 14 Feb 2026 14:27:01 +0000</pubDate>
      <link>https://dev.to/leo_rio/managing-production-firebase-infrastructure-multi-environment-deployment-for-a-react-pwa-1nfp</link>
      <guid>https://dev.to/leo_rio/managing-production-firebase-infrastructure-multi-environment-deployment-for-a-react-pwa-1nfp</guid>
      <description>&lt;p&gt;I built a multi-tenant PWA on Firebase this year: three environments, role-based access across organizations, offline support. Firebase makes the front-end wiring easy. What took real thought was splitting environments so a staging accident could never touch prod, and getting RBAC to work offline without an extra Firestore read on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Firebase projects, not one
&lt;/h2&gt;

&lt;p&gt;Firebase's free tier caps you at one database per project. Trying to run dev, staging, and prod on a single project is the fastest way to have your staging misconfigure and start writing to production collections.&lt;/p&gt;

&lt;p&gt;I use three completely separate Firebase projects: &lt;code&gt;my-app-dev&lt;/code&gt;, &lt;code&gt;my-app-staging&lt;/code&gt;, &lt;code&gt;my-app-production&lt;/code&gt;. Each has its own auth users, Firestore, storage bucket, and hosting deployment. Isolated security rules, isolated usage quotas, isolated service account permissions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Deployment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Development&lt;/td&gt;
&lt;td&gt;Local testing with emulator&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging&lt;/td&gt;
&lt;td&gt;Pre-production testing&lt;/td&gt;
&lt;td&gt;GitHub Actions on &lt;code&gt;staging&lt;/code&gt; push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;Live users&lt;/td&gt;
&lt;td&gt;GitHub Actions on &lt;code&gt;main&lt;/code&gt; push&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Environment config: explicit, not clever
&lt;/h2&gt;

&lt;p&gt;The layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;app/
├── .env                    &lt;span class="c"&gt;# Development (uses emulator)&lt;/span&gt;
├── .env.staging            &lt;span class="c"&gt;# Staging deployment&lt;/span&gt;
├── .env.production         &lt;span class="c"&gt;# Production deployment&lt;/span&gt;
└── src/
    └── config/
        └── firebase.ts     &lt;span class="c"&gt;# Firebase initialization&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Development &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;REACT_APP_FIREBASE_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-app-dev
&lt;span class="nv"&gt;REACT_APP_USE_EMULATOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="nv"&gt;REACT_APP_FIREBASE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-dev-api-key
&lt;span class="nv"&gt;REACT_APP_FIREBASE_AUTH_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-app-dev.firebaseapp.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production &lt;code&gt;.env.production&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;REACT_APP_FIREBASE_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-app-production
&lt;span class="nv"&gt;REACT_APP_USE_EMULATOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
&lt;/span&gt;&lt;span class="nv"&gt;REACT_APP_FIREBASE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-prod-api-key
&lt;span class="nv"&gt;REACT_APP_FIREBASE_AUTH_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-app-production.firebaseapp.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My worst near-miss was letting the build script guess which environment file to load. Something upstream flipped the environment detection, staging built with production credentials pointed at the production Firestore, and I didn't catch it for hours.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Red flag:&lt;/strong&gt; never let the build script guess the environment. Copy the file explicitly in the deploy step. If the command doesn't say the environment name in plain text, you have a foot-gun waiting to fire.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.production .env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Custom claims for multi-tenant RBAC
&lt;/h2&gt;

&lt;p&gt;The classic pattern is storing roles in Firestore and reading them on every request. It works, but every protected page then loads two documents: the data you actually want, and the role check that guards it. It also breaks the moment the network drops.&lt;/p&gt;

&lt;p&gt;Firebase Custom Claims live inside the JWT ID token. The client caches the token. Firestore security rules read the claims directly with no extra document lookup. And they work offline because the token is already on the device.&lt;/p&gt;

&lt;p&gt;Claims structure for a user who admins one organization and views another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CustomClaims&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// Platform super-admin&lt;/span&gt;
  &lt;span class="nl"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// Org-specific roles&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;isAdmin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;organizations&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;org_abc123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;org_xyz789&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no Firebase Console UI for custom claims. You set them via the Admin SDK. I keep a small script in &lt;code&gt;firebase-admin-scripts/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/set-user-claims.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;firebase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./service-account.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setUserClaims&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getUserByEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setCustomUserClaims&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;organizations&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Claims updated for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;setUserClaims&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;org_abc123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson learned:&lt;/strong&gt; custom claims cap at 1000 bytes per user. Enough for organization IDs and roles. For fine-grained per-resource permissions, keep those in Firestore and use claims only for the org membership set. Overload the claim and Firebase silently rejects future writes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Security rules that use the claims
&lt;/h2&gt;

&lt;p&gt;The rule below reads the org role straight from the token and applies different access per role, no extra Firestore read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;rules_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="nx"&gt;cloud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firestore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;databases&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/documents &lt;/span&gt;&lt;span class="err"&gt;{
&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasOrgAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowedRoles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
             &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;allowedRoles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;organizations&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasOrgAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasOrgAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;organizations&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/documents/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;docId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasOrgAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasOrgAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasOrgAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test rules before deploying. Firebase Console → Firestore → Rules → Rules Playground simulates a user with specific claims against a specific path. I also keep unit tests using &lt;code&gt;@firebase/rules-unit-testing&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Organization access&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allows an org member to read documents&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getFirestore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testEnv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;org_abc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;organizations&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;org_abc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertSucceeds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denies non-member access&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getFirestore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testEnv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user456&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;organizations&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;org_abc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertFails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip these tests and you'll ship a rule that permits more than you think. There is no runtime warning, just quiet exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment pipeline
&lt;/h2&gt;

&lt;p&gt;Push to &lt;code&gt;staging&lt;/code&gt;, GitHub Actions builds and pushes to the staging Firebase project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy-staging.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Staging&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;staging&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Node.js&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;18'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install pnpm&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g pnpm&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm install&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build for staging&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;cp .env.staging .env&lt;/span&gt;
          &lt;span class="s"&gt;pnpm build&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Firebase Hosting&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FirebaseExtended/action-hosting-deploy@v0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;repoToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secrets.GITHUB_TOKEN&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;firebaseServiceAccount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secrets.FIREBASE_SERVICE_ACCOUNT_STAGING&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
          &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-staging&lt;/span&gt;
          &lt;span class="na"&gt;channelId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;live&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the Firebase service account JSON as a GitHub secret. Never commit it. The account has full write access to the Firestore database it belongs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backup strategy for production
&lt;/h2&gt;

&lt;p&gt;Three layers, each covering a different failure mode.&lt;/p&gt;

&lt;p&gt;Daily backups with 7-day retention catch accidents you notice within the week: dropped collection, bad migration you spot on Monday.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud firestore backups schedules create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--database&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'(default)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--recurrence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;daily &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--retention&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;7d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Weekly backups with 8-week retention catch slow-burn corruption you don't notice for a month: a bad migration that only affects new writes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud firestore backups schedules create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--database&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'(default)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--recurrence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;weekly &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--retention&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8w &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--day-of-week&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SUN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Monthly exports to Cloud Storage with 365-day retention are the everything-is-on-fire recovery: full portable dump, retrievable a year out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gsutil mb &lt;span class="nt"&gt;-l&lt;/span&gt; us-central1 gs://my-app-backups

gcloud scheduler &lt;span class="nb"&gt;jobs &lt;/span&gt;create http firestore-monthly-export &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--schedule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0 2 1 * *"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://firestore.googleapis.com/v1/projects/my-app-production/databases/(default):exportDocuments"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--http-method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--oauth-service-account-email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;backup-sa@my-app-production.iam.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Content-Type=application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--message-body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"outputUriPrefix":"gs://my-app-backups/monthly"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Native daily and weekly backups are free. You pay only when you restore. Cloud Storage exports run about $0.02-0.05/GB/month. For most apps under a few GB, all three layers combined stay under $5 a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firebase Emulator: not optional
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;firebase emulators:start &lt;span class="nt"&gt;--only&lt;/span&gt; auth,firestore,storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip this and you'll burn through your Firebase read quota on trivial dev work, or worse, write test data into production because you forgot to swap credentials. The emulator gives you zero API quota consumption, instant reset by restarting the process, offline development, and no chance of a stray test write hitting production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually matters
&lt;/h2&gt;

&lt;p&gt;The Firebase parts people underestimate are the ones that survive an incident, not the ones that make the app feel snappy.&lt;/p&gt;

&lt;p&gt;Separate projects per environment eliminate the entire class of "staging leaked into prod" bugs. Custom claims give you RBAC that works offline and doesn't cost a Firestore read per page load, but remember the 1000-byte cap. Three-tier backups cover three failure modes for the price of a coffee per month. And the Firebase Emulator is the difference between working locally without thinking and getting surprised by a bill.&lt;/p&gt;

&lt;p&gt;References I keep bookmarked: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://firebase.google.com/docs/auth/admin/custom-claims" rel="noopener noreferrer"&gt;Custom Claims docs&lt;/a&gt;, &lt;/li&gt;
&lt;li&gt;
&lt;a href="https://firebase.google.com/docs/firestore/security/get-started" rel="noopener noreferrer"&gt;Firestore Security Rules&lt;/a&gt;, &lt;/li&gt;
&lt;li&gt;
&lt;a href="https://firebase.google.com/docs/emulator-suite" rel="noopener noreferrer"&gt;Emulator Suite&lt;/a&gt;,&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cloud.google.com/firestore/docs/backups" rel="noopener noreferrer"&gt;Backup Schedules&lt;/a&gt;,&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://firebase.google.com/docs/rules/unit-tests" rel="noopener noreferrer"&gt;Rules unit testing&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Questions about Firebase infrastructure or DevOps? Connect with me on &lt;a href="https://www.linkedin.com/in/leodandyy/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or visit my &lt;a href="https://leo-rio.com" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>react</category>
      <category>devops</category>
      <category>deployment</category>
    </item>
    <item>
      <title>Emergency Server Recovery: A 4-Hour Race Against Time</title>
      <dc:creator>hello world_leo</dc:creator>
      <pubDate>Tue, 18 Nov 2025 15:57:06 +0000</pubDate>
      <link>https://dev.to/leo_rio/emergency-server-recovery-a-4-hour-race-against-time-1di5</link>
      <guid>https://dev.to/leo_rio/emergency-server-recovery-a-4-hour-race-against-time-1di5</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## The 3AM Wake-Up Call
You know that feeling when your phone buzzes at an ungodly hour and your stomach drops? That was me, staring at a frantic message: "Site is showing weird content. Help!"

I grabbed my laptop. The WordPress site was serving pharmaceutical spam to visitors. Classic compromise. The clock started ticking.

## Hour 1: Damage Assessment (03:00 - 04:00)
First rule of server emergencies: don't panic, but move fast.
What I Did First
SSH into the server, check if I still had access. Good news: credentials still worked. Bad news: everything else.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Check recent file modifications
&lt;/h1&gt;

&lt;p&gt;find /var/www/html -type f -mtime -7 -ls | head -20&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tons of suspicious PHP files scattered everywhere. The wp-content/uploads folder was full of backdoors. Someone had gotten in through an outdated plugin, probably.
Quick Isolation
Pulled the site offline with a maintenance page. Better to show "down for maintenance" than spam pills to your visitors.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Quick nginx block
&lt;/h1&gt;

&lt;p&gt;location / {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return 503;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Took a snapshot of everything before touching anything. You need evidence, and you might need to rollback if things go sideways.

## Hour 2: The Cleanup (04:00 - 05:00)
Finding the Entry Point
Checked Apache/Nginx logs for unusual POST requests:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;grep -i "POST" /var/log/nginx/access.log | grep -E ".(php|asp|jsp)" | tail -100&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Found it. An old contact form plugin with a known vulnerability. They uploaded a shell through a file upload field that wasn't properly validated.
Nuclear Option with Surgical Precision
Here's the thing about compromised WordPress sites: you can't trust anything. But you also can't just delete everything because you need the data.

My approach:

1. Backed up the database (even though it might be compromised)
2. Downloaded all uploaded media files
3. Saved the wp-config.php (to get database credentials)
4. Nuked everything else

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Backup first
&lt;/h4&gt;

&lt;p&gt;mysqldump -u dbuser -p dbname &amp;gt; backup_$(date +%Y%m%d_%H%M%S).sql&lt;/p&gt;

&lt;h4&gt;
  
  
  Fresh WordPress install
&lt;/h4&gt;

&lt;p&gt;wget &lt;a href="https://wordpress.org/latest.tar.gz" rel="noopener noreferrer"&gt;https://wordpress.org/latest.tar.gz&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;tar -xzf latest.tar.gz&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;
&lt;span class="k"&gt;Database&lt;/span&gt; &lt;span class="n"&gt;Surgery&lt;/span&gt;
&lt;span class="n"&gt;The&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt; &lt;span class="n"&gt;had&lt;/span&gt; &lt;span class="n"&gt;malicious&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;these&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="nv"&gt;`wp_options`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autoload&lt;/span&gt; &lt;span class="n"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;`wp_posts`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spam&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="n"&gt;injected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;`wp_users`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;admin&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;Cleaned&lt;/span&gt; &lt;span class="n"&gt;them&lt;/span&gt; &lt;span class="n"&gt;manually&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;Yes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manually&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;Running&lt;/span&gt; &lt;span class="n"&gt;automated&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;compromised&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;asking&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;trouble&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;-- Remove suspicious admin users&lt;/p&gt;

&lt;p&gt;SELECT * FROM wp_users WHERE user_login NOT IN ('known_admin_1', 'known_admin_2');&lt;/p&gt;

&lt;p&gt;DELETE FROM wp_users WHERE ID = [suspicious_id];&lt;/p&gt;

&lt;p&gt;-- Check for injected JavaScript in posts&lt;/p&gt;

&lt;p&gt;SELECT ID, post_title FROM wp_posts &lt;/p&gt;

&lt;p&gt;WHERE post_content LIKE '%&amp;lt;script%' &lt;/p&gt;

&lt;p&gt;OR post_content LIKE '%iframe%';&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
## Hour 3: Hardening &amp;amp; Recovery (05:00 - 06:00)

The Rebuild
Fresh WordPress core, clean database, restored media files. Now comes the part most people skip: actually securing the thing.
File Permissions That Make Sense

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Directories: 755
&lt;/h1&gt;

&lt;p&gt;find /var/www/html -type d -exec chmod 755 {} \;&lt;/p&gt;

&lt;h1&gt;
  
  
  Files: 644
&lt;/h1&gt;

&lt;p&gt;find /var/www/html -type f -exec chmod 644 {} \;&lt;/p&gt;

&lt;h1&gt;
  
  
  wp-config.php: 440
&lt;/h1&gt;

&lt;p&gt;chmod 440 /var/www/html/wp-config.php&lt;/p&gt;

&lt;p&gt;chown www-data:www-data /var/www/html/wp-config.php&lt;br&gt;
Disable File Editing&lt;br&gt;
Added to wp-config.php:&lt;/p&gt;

&lt;p&gt;define('DISALLOW_FILE_EDIT', true);&lt;/p&gt;

&lt;p&gt;define('DISALLOW_FILE_MODS', true);&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
No more editing themes from the admin panel. If you need to update something, do it through SFTP like a proper developer.
Web Application Firewall
Configured ModSecurity with OWASP rules. Basic stuff:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;apt-get install libapache2-mod-security2&lt;/p&gt;

&lt;p&gt;cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;Changed SecRuleEngine DetectionOnly to SecRuleEngine On.

The Forgotten Hero: Fail2Ban
&lt;span class="nc"&gt;Set&lt;/span&gt; up Fail2Ban to &lt;span class="ss"&gt;block&lt;/span&gt; brute force attempts:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  /etc/fail2ban/jail.local
&lt;/h1&gt;

&lt;p&gt;[wordpress]&lt;/p&gt;

&lt;p&gt;enabled = true&lt;/p&gt;

&lt;p&gt;filter = wordpress&lt;/p&gt;

&lt;p&gt;logpath = /var/log/auth.log&lt;/p&gt;

&lt;p&gt;maxretry = 3&lt;/p&gt;

&lt;p&gt;bantime = 3600&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
&lt;span class="c"&gt;## Hour 4: Automation &amp;amp; Insurance (06:00 - 07:00)&lt;/span&gt;

Automated Backups That Actually Work
Wrote a bash script &lt;span class="k"&gt;for &lt;/span&gt;daily backups to remote storage:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  !/bin/bash
&lt;/h1&gt;

&lt;p&gt;TIMESTAMP=$(date +%Y%m%d_%H%M%S)&lt;/p&gt;

&lt;p&gt;BACKUP_DIR="/backups"&lt;/p&gt;

&lt;h1&gt;
  
  
  Database
&lt;/h1&gt;

&lt;p&gt;mysqldump -u user -ppassword database &amp;gt; $BACKUP_DIR/db_$TIMESTAMP.sql&lt;/p&gt;

&lt;h1&gt;
  
  
  Files
&lt;/h1&gt;

&lt;p&gt;tar -czf $BACKUP_DIR/files_$TIMESTAMP.tar.gz /var/www/html&lt;/p&gt;

&lt;h1&gt;
  
  
  Send to remote (S3, or whatever)
&lt;/h1&gt;

&lt;p&gt;aws s3 cp $BACKUP_DIR/ s3://your-bucket/backups/ --recursive&lt;/p&gt;

&lt;h1&gt;
  
  
  Keep only last 7 days locally
&lt;/h1&gt;

&lt;p&gt;find $BACKUP_DIR -type f -mtime +7 -delete&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
Added it to crontab: &lt;span class="sb"&gt;`&lt;/span&gt;0 2 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /home/scripts/backup.sh&lt;span class="sb"&gt;`&lt;/span&gt;

Monitoring Setup
Installed basic monitoring so next &lt;span class="nb"&gt;time &lt;/span&gt;we catch things early:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Uptime monitoring
&lt;/h1&gt;

&lt;p&gt;curl -X POST &lt;a href="https://cronitor.io/api/monitors" rel="noopener noreferrer"&gt;https://cronitor.io/api/monitors&lt;/a&gt; \&lt;/p&gt;

&lt;p&gt;-H "Content-Type: application/json" \&lt;/p&gt;

&lt;p&gt;-d '{"name": "client-site", "url": "&lt;a href="https://example.com%22%7D" rel="noopener noreferrer"&gt;https://example.com"}&lt;/a&gt;'&lt;/p&gt;

&lt;h1&gt;
  
  
  File integrity monitoring
&lt;/h1&gt;

&lt;p&gt;apt-get install aide&lt;/p&gt;

&lt;p&gt;aide --init&lt;br&gt;
SSL &amp;amp; Security Headers&lt;br&gt;
Fresh SSL certificate with Let's Encrypt:&lt;/p&gt;

&lt;p&gt;certbot --nginx -d example.com -d &lt;a href="http://www.example.com" rel="noopener noreferrer"&gt;www.example.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Added security headers to nginx:&lt;/p&gt;

&lt;p&gt;add_header X-Frame-Options "SAMEORIGIN" always;&lt;/p&gt;

&lt;p&gt;add_header X-Content-Type-Options "nosniff" always;&lt;/p&gt;

&lt;p&gt;add_header X-XSS-Protection "1; mode=block" always;&lt;/p&gt;

&lt;p&gt;add_header Referrer-Policy "strict-origin-when-cross-origin" always;&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


## The Aftermath
By 7AM, site was back online. Clean, secured, monitored.

What the client saw: Their site back up, faster than before, with new security measures.

What they didn't see: The 4 hours of SSH sessions, database queries, and three cups of coffee.

## Lessons From The Trenches

### What Worked

- Having a clear mental checklist for compromises
- Not trusting anything on a compromised system
- Taking backups before any action (even if you think you don't need them)
- Hardening during recovery, not after

### What I'd Do Different
- Should have set up monitoring earlier (obviously)
- Could have automated the cleanup scripts better
- Next time: keep a USB drive with common tools ready

### Prevention Is Cheaper Than Cure
After this incident, I set up these things for all client sites:

- Weekly automated backups (tested restores monthly)
- Security plugins with proper configuration
- Update automation for core/plugins/themes
- File integrity monitoring
- Login attempt limiting

### The Technical Stack Behind This Recovery
- OS: Ubuntu 20.04 LTS
- Web Server: Nginx 1.18
- Database: MySQL 8.0
- Backup Storage: AWS S3
- Monitoring: Uptime Robot + custom bash scripts
- Security: ModSecurity, Fail2Ban, Cloudflare WAF

## Final Thoughts
Emergency recoveries are stressful. Your hands shake a bit when you're running rm -rf on a production server at 5AM. But this is what separates someone who just "knows WordPress" from someone who actually understands infrastructure.

The client was happy. The site survived. And I learned (again) that keeping systems updated and monitored is way easier than 4-hour emergency sessions.

Now I keep this checklist printed and stuck to my monitor. Because there will be a next time. There's always a next time.

------

Time taken: 4 hours
Coffee consumed: 3 cups
Client panic level: Reduced from 10/10 to 2/10
Would I do it again: Absolutely. But let's try to avoid it, yeah?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>5 WordPress Performance Issues I Fix Every Week (And How You Can Too)</title>
      <dc:creator>hello world_leo</dc:creator>
      <pubDate>Fri, 26 Sep 2025 13:41:00 +0000</pubDate>
      <link>https://dev.to/leo_rio/5-wordpress-performance-issues-i-fix-every-week-and-how-you-can-too-34go</link>
      <guid>https://dev.to/leo_rio/5-wordpress-performance-issues-i-fix-every-week-and-how-you-can-too-34go</guid>
      <description>&lt;p&gt;I've audited a lot of WordPress sites for clients who complained their pages felt slow. The same five things come up almost every time. None of them are exotic. All of them get worse quietly. None of them fix themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;Every second on page load costs conversions. Industry estimates put the hit at roughly 7% per second on e-commerce; even with generous rounding, a slow site is money left on the table. For a $100K/month store, a single second is $7K/month. For a service business, it's leads that never call.&lt;/p&gt;

&lt;p&gt;I've seen 8-second WordPress home pages. All of them fixable, but only once you know where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Database bloat
&lt;/h2&gt;

&lt;p&gt;WordPress databases accumulate junk. Spam comments, post revisions (default: unlimited), expired transients, orphaned metadata from plugins that got uninstalled but left their tables behind. I recently audited a 2-year-old site with a 400MB database. Actual content was 50MB.&lt;/p&gt;

&lt;p&gt;Start with a size check by table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="s1"&gt;'Table'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="n"&gt;data_length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index_length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="s1"&gt;'Size (MB)'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLES&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_database_name'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index_length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sort by size, look at what's inflating. Common suspects: &lt;code&gt;wp_options&lt;/code&gt; with autoloaded transient data, &lt;code&gt;wp_postmeta&lt;/code&gt; from a form plugin that never cleans up submissions, &lt;code&gt;wp_comments&lt;/code&gt; with 40,000 spam rows.&lt;/p&gt;

&lt;p&gt;Cleanup tools like WP-Optimize handle the basics. For &lt;code&gt;wp_options&lt;/code&gt; autoload issues, do it manually with SQL and measure query time before and after. One client's site dropped from 6.2s to 3.8s just from database cleanup and setting &lt;code&gt;WP_POST_REVISIONS&lt;/code&gt; to a sane number in &lt;code&gt;wp-config.php&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Unrestrained images
&lt;/h2&gt;

&lt;p&gt;The most common thing I see is a 5MB hero image loading on mobile. A photography portfolio I inherited had 47 images totaling 180MB on the homepage. On 4G, that's a coffee break to load.&lt;/p&gt;

&lt;p&gt;Three fixes, in order of leverage.&lt;/p&gt;

&lt;p&gt;WebP conversion. WordPress supports the format if you register the MIME type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// functions.php&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;add_webp_support&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mimes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$mimes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'webp'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'image/webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$mimes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'upload_mimes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'add_webp_support'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then batch-convert the existing library (any WebP plugin will do it with JPEG fallback for older browsers).&lt;/p&gt;

&lt;p&gt;Responsive sizes. WordPress generates them automatically but only if the theme uses &lt;code&gt;wp_get_attachment_image()&lt;/code&gt; or the block editor's image block. Sites hand-coded three years ago often bypass this and serve one giant asset to every screen.&lt;/p&gt;

&lt;p&gt;CDN with image transforms. Cloudflare's free tier resizes and reformats on the fly if you enable Polish and Mirage. Zero code changes, immediate weight reduction.&lt;/p&gt;

&lt;p&gt;An e-commerce client I worked with went from 8.2MB homepage weight to 1.4MB. Load time improved by 65%.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Plugin overload
&lt;/h2&gt;

&lt;p&gt;I audited a small business site running 47 active plugins. One was a full e-commerce suite, active because someone had briefly considered selling something two years ago and never turned it off. The contact form was a separate plugin. So was the "recent posts" widget. And the newsletter. And the redirects. And the schema markup.&lt;/p&gt;

&lt;p&gt;The audit is dumb but works: list every plugin, deactivate them one at a time, measure page load between each. The ones with big TTFB improvements are the culprits.&lt;/p&gt;

&lt;p&gt;Common offenders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Page builders (Elementor, Divi) shipping 2MB+ of CSS and JS to render a landing page a static HTML file would render in 50KB&lt;/li&gt;
&lt;li&gt;Social sharing plugins loading 20+ networks nobody uses&lt;/li&gt;
&lt;li&gt;Two SEO plugins because someone installed Yoast, then someone else installed Rank Math, and nobody disabled the other&lt;/li&gt;
&lt;li&gt;Backup plugins scheduled during peak hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Removing 12 plugins from that 47-plugin site dropped Time to First Byte from 2.1s to 0.8s.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. No caching, or worse, wrong caching
&lt;/h2&gt;

&lt;p&gt;About 40% of the sites I audit have no caching at all. Another 30% have caching configured in a way that actively hurts them, most commonly a WordPress caching plugin fighting a server-level cache, both trying to store the same responses with different invalidation rules. Every deploy purges one but not the other. Every editorial change bypasses one but is stuck in the other.&lt;/p&gt;

&lt;p&gt;The stack that works for most WordPress sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server level: Nginx FastCGI cache (or Apache mod_cache). Serves static HTML for cached responses without touching PHP.&lt;/li&gt;
&lt;li&gt;Application level: Redis object cache for query results. Cuts database round-trips on complex pages.&lt;/li&gt;
&lt;li&gt;Browser level: proper &lt;code&gt;Cache-Control&lt;/code&gt; headers on static assets.&lt;/li&gt;
&lt;li&gt;CDN: Cloudflare or similar in front of everything for edge caching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Minimum Nginx FastCGI cache config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.php$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_cache_valid&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="mi"&gt;60m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_cache_valid&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="mi"&gt;10m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_no_cache&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# ... other fastcgi settings&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$skip_cache&lt;/code&gt; should trip for logged-in users, POST requests, and any admin path (&lt;code&gt;wp-admin&lt;/code&gt;, &lt;code&gt;wp-login.php&lt;/code&gt;, &lt;code&gt;xmlrpc.php&lt;/code&gt;). Get this wrong and different users see each other's carts.&lt;/p&gt;

&lt;p&gt;A high-traffic news site went from 1.2s server response time to 180ms after this configuration landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Hosting mismatch
&lt;/h2&gt;

&lt;p&gt;Running a WordPress site with real traffic on $5/month shared hosting is optimizing the wrong side of the equation. You'll spend weeks tuning the site while the underlying constraint is a shared kernel with 400 other tenants competing for CPU.&lt;/p&gt;

&lt;p&gt;Red flags on a hosting audit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shared hosting for sites with 10,000+ monthly visitors&lt;/li&gt;
&lt;li&gt;No SSD storage (yes, this still happens)&lt;/li&gt;
&lt;li&gt;PHP version older than 8.1&lt;/li&gt;
&lt;li&gt;No option for server-level caching&lt;/li&gt;
&lt;li&gt;Memory limit at 128MB or lower&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rough sizing guide by monthly traffic:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Visitors/month&lt;/th&gt;
&lt;th&gt;Right tier&lt;/th&gt;
&lt;th&gt;Cost range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0-5K&lt;/td&gt;
&lt;td&gt;Quality shared hosting&lt;/td&gt;
&lt;td&gt;$10-20/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5K-50K&lt;/td&gt;
&lt;td&gt;Properly configured VPS&lt;/td&gt;
&lt;td&gt;$20-50/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50K+&lt;/td&gt;
&lt;td&gt;Managed WordPress or custom VPS&lt;/td&gt;
&lt;td&gt;$50+/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Moving one client from $8/month shared to a $25/month VPS took their site from 4.5s to 1.8s. Same code, same content, different machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;If you're only going to fix one thing today, do the database cleanup. An afternoon of work, immediate visible impact, no product tradeoffs. If you're fixing two, right-size the hosting. Those two together cover most of the wins on a neglected WordPress site.&lt;/p&gt;

&lt;p&gt;The other three (images, plugins, caching) touch product decisions. Which images belong on the homepage. Which plugins the team actually depends on. Which cache invalidation strategy fits the editorial workflow. Those conversations belong with the site owners, not the DevOps engineer running the audit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plugin optimization:&lt;/strong&gt; 15-40% speed improvement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proper caching:&lt;/strong&gt; 30-70% speed improvement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting upgrade:&lt;/strong&gt; 25-60% speed improvement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Combined effect:&lt;/strong&gt; Most sites see 40-70% overall improvement when all issues are addressed properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes to Avoid
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Over-optimization:&lt;/strong&gt; Don't chase perfect scores, chase good user experience&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugin addiction:&lt;/strong&gt; More caching plugins doesn't mean better performance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring mobile:&lt;/strong&gt; 60%+ of traffic is mobile, optimize for it first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-time fixes:&lt;/strong&gt; Performance optimization needs ongoing maintenance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap shortcuts:&lt;/strong&gt; Free solutions often cost more in the long run&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Your Next Steps
&lt;/h2&gt;

&lt;p&gt;If your WordPress site is slow, start with these priorities:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run a performance audit&lt;/strong&gt; - Get baseline numbers first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean your database&lt;/strong&gt; - Often the biggest quick win&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimize images&lt;/strong&gt; - Especially if you have a visual site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit plugins&lt;/strong&gt; - Remove what you don't actually need&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up proper caching&lt;/strong&gt; - This can transform your site&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Performance optimization isn't just about technical tweaks - it's about creating better user experiences that convert visitors into customers.&lt;/p&gt;




</description>
      <category>tutorial</category>
      <category>performance</category>
      <category>wordpress</category>
      <category>php</category>
    </item>
  </channel>
</rss>
