The Problem

Render's free and hobby plans use ephemeral storage. Every time your service restarts, scales down, or deploys:

  • Your SQLite .db file is deleted
  • All user data, settings, and records are lost
  • You wake up to angry users and empty tables

The paid workaround is a managed PostgreSQL addon ($$). The free workaround is this guide.


The Solution

Use two MTD API endpoints:

Endpoint What it does
POST /api/backup Uploads your .db file to our secured Storage via MTD
GET /api/restore/{filename} Downloads the latest backup so you can restore it on startup

Your app calls backup after every important write. On startup, it calls restore to pull the latest snapshot before serving any traffic. The database lives in our Secured storage — your Render disk is just a temporary working copy.


Prerequisites


Required Headers

X-API-KEY: mtd_key1234567890ABCD
User-Agent: MyApp/1.0
Referer: https://myapp.onrender.com

Step 1 — Backup Your Database

Endpoint

POST /api/backup
Content-Type: multipart/form-data

Send your .db file as a form field named file.

Response (success):

{
  "success": true,
  "message": "Backup uploaded.",
  "filename": "a1b2c3d4_myapp.db",
  "size": 204800,
  "credits_remaining": 48
}

Save the filename from this response — you need it to restore later. Store it in an environment variable or a separate tiny config file.


Step 2 — Restore on Startup

Endpoint

GET /api/restore/{filename}

The response body is the raw binary .db file. Write it directly to disk.


Full Implementation

Python — utils/backup.py and utils/restore.py

Drop these two files into your project. The MTD API app itself uses exactly this pattern.

utils/backup.py

import os
import requests

API_KEY = os.environ.get("MTD_API_KEY", "mtd_key1234567890ABCD")
DB_PATH = os.environ.get("DB_PATH", "myapp.db")
APP_URL = os.environ.get("APP_URL", "https://myapp.onrender.com")
BACKUP_FILENAME_PATH = "last_backup_filename.txt"  # where we store the filename

HEADERS = {
    "X-API-KEY": API_KEY,
    "User-Agent": "MyApp/1.0",
    "Referer": APP_URL,
}

def backup():
    """Upload the SQLite database to MTD API backup storage."""
    if not os.path.exists(DB_PATH):
        print(f"[backup] No database found at {DB_PATH}, skipping.")
        return None

    try:
        with open(DB_PATH, "rb") as f:
            response = requests.post(
                "https://api.motadev.xyz/api/backup",
                headers=HEADERS,
                files={"file": (os.path.basename(DB_PATH), f, "application/octet-stream")},
                timeout=60,
            )

        data = response.json()

        if data.get("success"):
            filename = data["filename"]
            # Persist the filename so restore() knows what to pull
            with open(BACKUP_FILENAME_PATH, "w") as f:
                f.write(filename)
            print(f"[backup] ✅ Backed up as: {filename} ({data['size']} bytes)")
            return filename
        else:
            print(f"[backup] ❌ Failed: {data.get('message')}")
            return None

    except Exception as e:
        print(f"[backup] ❌ Exception: {e}")
        return None

utils/restore.py

import os
import requests

API_KEY = os.environ.get("MTD_API_KEY", "mtd_key1234567890ABCD")
DB_PATH = os.environ.get("DB_PATH", "myapp.db")
APP_URL = os.environ.get("APP_URL", "https://myapp.onrender.com")
BACKUP_FILENAME_PATH = "last_backup_filename.txt"

HEADERS = {
    "X-API-KEY": API_KEY,
    "User-Agent": "MyApp/1.0",
    "Referer": APP_URL,
}

def restore():
    """Download the latest backup from MTD API and restore it as the active DB."""
    if not os.path.exists(BACKUP_FILENAME_PATH):
        print("[restore] No backup filename recorded. Starting fresh.")
        return False

    with open(BACKUP_FILENAME_PATH, "r") as f:
        filename = f.read().strip()

    if not filename:
        print("[restore] Backup filename is empty. Starting fresh.")
        return False

    try:
        response = requests.get(
            f"https://api.motadev.xyz/api/restore/{filename}",
            headers=HEADERS,
            timeout=60,
        )

        if response.status_code == 200:
            with open(DB_PATH, "wb") as f:
                f.write(response.content)
            print(f"[restore] ✅ Restored {len(response.content)} bytes from {filename}")
            return True
        else:
            # Response might be JSON error
            try:
                data = response.json()
                print(f"[restore] ❌ API error: {data.get('message')}")
            except Exception:
                print(f"[restore] ❌ HTTP {response.status_code}")
            return False

    except Exception as e:
        print(f"[restore] ❌ Exception: {e}")
        return False

app.py — Hook it into your Flask startup:

from utils.restore import restore
from utils.backup import backup

# At the very bottom of app.py, before app.run()
if __name__ == "__main__":
    init_db()   # create tables if they don't exist
    restore()   # pull latest backup from Supabase before serving traffic
    app.run(host="0.0.0.0", port=5000)

Trigger backup after critical writes:

# After a user registers
db.execute("INSERT INTO users ...")
db.commit()
backup()  # fire and forget — runs fast

# After a payment is confirmed
db.execute("UPDATE transactions SET status='complete' ...")
db.commit()
backup()

Node.js — utils/backup.js and utils/restore.js

// utils/backup.js
const fs = require("fs");
const path = require("path");
const FormData = require("form-data");
const axios = require("axios");

const API_KEY = process.env.MTD_API_KEY || "mtd_key1234567890ABCD";
const DB_PATH = process.env.DB_PATH || "myapp.db";
const APP_URL = process.env.APP_URL || "https://myapp.onrender.com";
const FILENAME_PATH = "last_backup_filename.txt";

async function backup() {
  if (!fs.existsSync(DB_PATH)) {
    console.log("[backup] No database found, skipping.");
    return null;
  }

  try {
    const form = new FormData();
    form.append("file", fs.createReadStream(DB_PATH), {
      filename: path.basename(DB_PATH),
      contentType: "application/octet-stream",
    });

    const response = await axios.post("https://api.motadev.xyz/api/backup", form, {
      headers: {
        ...form.getHeaders(),
        "X-API-KEY": API_KEY,
        "User-Agent": "MyApp/1.0",
        "Referer": APP_URL,
      },
      timeout: 60000,
    });

    const data = response.data;
    if (data.success) {
      fs.writeFileSync(FILENAME_PATH, data.filename);
      console.log(`[backup] ✅ Backed up as: ${data.filename} (${data.size} bytes)`);
      return data.filename;
    } else {
      console.error(`[backup] ❌ Failed: ${data.message}`);
      return null;
    }
  } catch (err) {
    console.error("[backup] ❌ Exception:", err.message);
    return null;
  }
}

module.exports = { backup };
// utils/restore.js
const fs = require("fs");
const axios = require("axios");

const API_KEY = process.env.MTD_API_KEY || "mtd_key1234567890ABCD";
const DB_PATH = process.env.DB_PATH || "myapp.db";
const APP_URL = process.env.APP_URL || "https://myapp.onrender.com";
const FILENAME_PATH = "last_backup_filename.txt";

async function restore() {
  if (!fs.existsSync(FILENAME_PATH)) {
    console.log("[restore] No backup on record. Starting fresh.");
    return false;
  }

  const filename = fs.readFileSync(FILENAME_PATH, "utf8").trim();
  if (!filename) {
    console.log("[restore] Empty filename. Starting fresh.");
    return false;
  }

  try {
    const response = await axios.get(
      `https://api.motadev.xyz/api/restore/${filename}`,
      {
        headers: {
          "X-API-KEY": API_KEY,
          "User-Agent": "MyApp/1.0",
          "Referer": APP_URL,
        },
        responseType: "arraybuffer",
        timeout: 60000,
      }
    );

    fs.writeFileSync(DB_PATH, Buffer.from(response.data));
    console.log(`[restore] ✅ Restored ${response.data.byteLength} bytes from ${filename}`);
    return true;
  } catch (err) {
    console.error("[restore] ❌ Exception:", err.message);
    return false;
  }
}

module.exports = { restore };

server.js — Startup flow:

const { restore } = require("./utils/restore");
const { backup } = require("./utils/backup");
const { initDb } = require("./db");

async function start() {
  await initDb();    // create tables
  await restore();   // pull latest snapshot before first request

  const app = require("./app");
  app.listen(process.env.PORT || 3000, () => {
    console.log("Server running");
  });
}

start();

How the Startup Sequence Works

Render deploy starts
      │
      ▼
init_db() ──► Create tables if they don't exist (idempotent, safe)
      │
      ▼
restore() ──► Read last_backup_filename.txt
      │          └── GET /api/restore/{filename}
      │          └── Write binary response to myapp.db
      │
      ▼
app.run() ──► Now serving traffic with the restored database
      │
      ▼
(user actions)
      │
      ▼
db.commit() + backup() ──► POST /api/backup → upload .db → save new filename

Environment Variables

Set these in your Render dashboard under Environment:

MTD_API_KEY=mtd_key1234567890ABCD
DB_PATH=myapp.db
APP_URL=https://yourapp.onrender.com

Backup Frequency — What Makes Sense

Trigger Worth backing up?
New user registered ✅ Yes
Password changed ✅ Yes
Payment confirmed ✅ Absolutely
Admin settings changed ✅ Yes
Read-only GET request ❌ No — no data changed
Every API call ❌ No — too many credits

A good rule: back up after every write that would hurt if lost. Don't back up on reads. Don't back up inside loops.


Costs

Each backup upload costs 1 credit. Each restore download costs 0 credits.

On the free plan (50 credits), that gives you 50 backup events before you need to top up. For active apps, a Starter plan (500 credits at R99) covers thousands of deploy cycles and daily writes comfortably.


Troubleshooting

[restore] No backup on record — This is normal on first deploy. The app creates the DB fresh via init_db(). Run it, create some data, and the first backup() call will record the filename.

[backup] ❌ Failed: Insufficient credits — Top up at api.motadev.xyz/billing/buy.

[restore] ❌ HTTP 404 — The filename in last_backup_filename.txt doesn't match any backup in storage. This can happen if you deleted uploads from the dashboard. Delete the file and let the app start fresh, then make a new backup immediately.

Database is being restored but data is still missing — Make sure restore() is called before init_db() tries to insert seed data, or that init_db() uses INSERT OR IGNORE / CREATE TABLE IF NOT EXISTS so it doesn't overwrite restored rows.

Render keeps restarting and burning credits — If your free dyno spins down every 15 minutes, each cold start calls restore() (free) and later backup() after the first write. Use a cron pinger like UptimeRobot to keep it warm, reducing unnecessary cold starts.