The Problem
Render's free and hobby plans use ephemeral storage. Every time your service restarts, scales down, or deploys:
- Your SQLite
.dbfile 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
- MTD API account with API key → api.motadev.xyz/dashboard/settings
- A SQLite database in your app (
.db,.sqlite, or.sqlite3) - Python or Node.js in your project
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
filenamefrom 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.
0 Comments
Join the conversation
No comments yet. Be the first!