Initializing Systems...

High Risk

We Migrated 2TB of Production Data Without a Backup. Here’s What Happened.

Sometimes you learn the most when everything that could go wrong, does. This is the story of our most chaotic database migration and the surprisingly elegant solution we stumbled into.

It was 2:47 AM on a Tuesday when I realized we had no way back. The old database was already wiped. The new one was half-corrupted. And somewhere in the void between them floated 2TB of production data belonging to 47,000 active users.

This is not a story about best practices. This is a story about what happens when you ignore them.

The Setup

We were running a PostgreSQL 12 instance on a single dedicated server. It had served us well for three years, but we’d outgrown it. Query times were creeping up. The disk was at 87% capacity. Our ops team (me, myself, and I) had been planning a migration to a managed database cluster for months.

The plan was simple:

migration-plan.md
1. Spin up new managed PostgreSQL cluster
2. Set up logical replication from old β†’ new
3. Let it sync for 48 hours
4. Verify data integrity
5. Switch DNS, update connection strings
6. Keep old server as backup for 30 days
7. Celebrate

What actually happened:

what-actually-happened.md
1. Spin up new managed PostgreSQL cluster βœ“
2. Realize logical replication won't work 
   (forgot about the 47 tables without primary keys)
3. Panic
4. Decide to do a pg_dump instead
5. Start dump at 11 PM "so users won't notice"
6. Dump takes 9 hours (not the estimated 2)
7. Restore starts failing at 3 AM
8. Old server gets terminated by automation script
9. Chaos
⚠

Lesson #1: Automation Is Not Your Friend at 3 AM

We had a cleanup script that terminated “unused” servers after 24 hours of low CPU usage. During the dump, the old server was basically idle. You can guess what happened next.

The Crisis

At 3:12 AM, I got the Slack notification that still haunts me:

πŸ€– InfraBot 3:12 AM

Server db-prod-01 has been terminated. Reason: idle resource cleanup. Cost saved: $847/month πŸŽ‰

That celebration emoji. I will never forgive that celebration emoji.

The pg_dump had completed, but the restore was only 60% done when the source server vanished. We had:

  • βœ“ All user account data
  • βœ“ Transaction history
  • βœ“ Product catalog
  • βœ— User preferences and settings
  • βœ— Analytics events (last 90 days)
  • βœ— Uploaded files metadata
  • βœ— API keys and integrations

About 400GB of data was just… gone. No backup. No replica. No snapshot. Gone.

The Unlikely Save

Here’s where the story takes a turn. At 4 AM, running on cold coffee and regret, I remembered something: our CDN.

Three months earlier, we’d implemented edge caching for our API responses. The cache was aggressiveβ€”some endpoints had 24-hour TTLs. And crucially, we’d enabled cache persistence to survive deployments.

The CDN was still serving cached responses. Which meant…

The CDN had cached copies of almost every user’s preferences and settings from API responses made in the last 24 hours.

It wasn’t a backup. It was better described as “archaeological evidence.” But it was something.

I wrote the jankiest Python script of my career:

recover_from_cdn.py Python
import requests
import json
from concurrent.futures import ThreadPoolExecutor

CDN_DIAGNOSTIC_API = "https://cdn.example.com/diagnostic"
RECOVERED_DATA = []

def extract_cached_user_data(user_id):
    """
    Hit the CDN diagnostic endpoint to retrieve 
    cached API responses for a specific user.
    Yes, this is cursed. Yes, it worked.
    """
    endpoints = [
        f"/api/users/{user_id}/preferences",
        f"/api/users/{user_id}/settings", 
        f"/api/users/{user_id}/integrations",
    ]
    
    user_data = {"user_id": user_id}
    
    for endpoint in endpoints:
        try:
            # CDN diagnostic mode returns cached content
            resp = requests.get(
                CDN_DIAGNOSTIC_API,
                params={"inspect_path": endpoint},
                headers={"X-Diag-Key": DIAG_KEY}
            )
            if resp.status_code == 200:
                cached = resp.json()
                if cached.get("cache_hit"):
                    user_data[endpoint] = cached["cached_body"]
        except Exception as e:
            print(f"Failed {endpoint}: {e}")
    
    return user_data

# Recover data for all 47,000 users
# This took 6 hours. Worth it.
with ThreadPoolExecutor(max_workers=20) as executor:
    results = executor.map(
        extract_cached_user_data, 
        all_user_ids
    )
    RECOVERED_DATA = list(results)

Six hours later, we had recovered:

  • βœ“ 94% of user preferences
  • βœ“ 87% of user settings
  • βœ“ 76% of integration configs
  • ◐ 23% of uploaded files metadata (from recent API calls)

The analytics data was truly gone. We sent users an apologetic email and gave everyone a free month. The 6% of users whose preferences we couldn’t recover got personalized apology emails and three free months.

What We Actually Learned

01

Backups Are Not Optional

This seems obvious. It is obvious. We knew it was obvious. We still didn’t have proper backups because “we’ll set that up after the migration.” Don’t be us.

02

Automation Needs Guardrails

Our cleanup script was doing exactly what we told it to do. The problem was we told it to do something dangerous without any safeguards. Now all our automation has explicit exclusion lists and requires manual confirmation for anything tagged “production.”

03

Your CDN Is a Partial Backup

Not intentionally. Not reliably. But in a crisis, every cached response is data you might be able to recover. We now treat CDN cache persistence as a legitimate (secondary) recovery mechanism.

04

Test Your Recovery, Not Just Your Backups

Having backups means nothing if you’ve never actually restored from them. We now do quarterly “fire drills” where we restore production data to a staging environment. Every. Single. Quarter.

Where We Are Now

It’s been 18 months since The Incident. Here’s our current setup:

Primary DB Managed PostgreSQL 15 (3-node cluster)
Backups Hourly snapshots, 30-day retention, geo-redundant
Replication Synchronous replica in separate region
Recovery Testing Automated weekly restore verification
Automation Safeguards Production resources require 72-hour manual approval for termination

Total cost of the new infrastructure: about $2,400/month more than before.

Cost of The Incident (refunds, engineering time, reputation): roughly $180,000.

The math was always obvious. We just needed a catastrophe to see it.

☠

Final Thought

If you’re reading this and thinking “we should really set up proper backups,” stop reading and go do it. Right now. This article will still be here when you get back. Your data might not be.

stuff