Do you use Notion a lot and are you worried about how they back up your data or what would happen if you accidentally lose it (or someone deletes it)?
Worry no further! In this tutorial, we will cover how you can automate backing up your data onto your own self-hosted infrastructure using a Python script. We will use Gitea due to its versioning capabilities (although you can use other source version control tools, self-hosted or not). So strap your seat belts and let’s dive in!
Here are the tools and requirements to make this effort happen:
Originally, I was working on creating an n8n workflow that would handle it, but as of late January 2026, due to an n8n Sandbox Escape vulnerability (CVE-2026-25049) that scored 10.0/10.0, n8n devs tightened the screws and running scripts for this purpose became impossible. What an opportunity to do some coding and get one’s hands dirty!
You can git clone the script from a Github repo. It is written in pure Python with no external dependencies. The following internal libraries are used:
When the script runs, it follows the following pipeline: load configuration → query Notion → convert to markdown → commit to Git → notify. More precisely:
.env file to learn which Notion databases to back up, where to push them and which optional features (AI naming, Discord) are enabled. It also loads sync-state.json, a small local file that tracks the last sync time for every page - this is how incremental sync works.last_edited_time against what's stored in the sync state. Only pages that have changed since the last run get processed, which keeps daily runs fast.
assets/ and attachments/ directories in the Git repo. This prevents orphaned files from piling up when images are added, removed, or renamed between syncs.0 on success or 1 if any errors occurred, making it easy to monitor via cron or any process supervisor.In case you would like to know how is each imported library used:
| Library | How it's used in the script |
|---|---|
json |
Parsing API responses from Notion and Gitea (or other Git solution), reading/writing the sync-state.json file, and building request bodies for all API calls |
base64 |
Encoding downloaded images and attachments into base64 strings, which is how the Gitea API expects file content to be submitted |
ssl |
Creating a custom SSL context that accepts self-signed certificates, needed for internal Gitea/Forgejo instances without public CA certs |
re |
Cleaning and transforming strings - used in slugify() to strip special characters from page titles, and in URL extension detection |
os |
Forced process exit via os._exit(130) in the SIGINT handler, ensuring immediate termination even during blocked network I/O |
sys |
Accessing command-line arguments (sys.argv), exiting with appropriate codes (sys.exit(0) for success, 1 for errors, 130 for interrupt), and writing to stderr |
time |
Rate limiting: time.sleep(0.34) between Notion API calls (~3 req/sec) and time.sleep(0.15) between Gitea API calls to avoid overloading either service |
logging |
Structured log output with timestamps and severity levels (INFO, WARNING, ERROR), used throughout for progress tracking and error reporting |
signal |
Registering a handler for SIGINT (Ctrl+C) that forces immediate exit, bypassing Python's default behaviour of waiting for blocked I/O to complete |
socket |
socket.gethostname() retrieves the machine's hostname, included in Discord notifications so you know which server ran the backup |
urllib.request |
All HTTP communication - Notion API queries, Gitea file commits, image/attachment downloads, Discord webhook posts, and AI naming requests |
urllib.error |
Catching and handling HTTP errors (HTTPError for status codes like 403/404) and connection failures (URLError) with descriptive error messages |
urllib.parse |
URL-encoding file paths segment by segment to handle spaces and special characters in filenames (e.g. Notion to Gitea Backup.json), and extracting file extensions from URLs |
datetime |
Generating ISO timestamps for markdown front matter and commit messages, comparing page edit times against last sync times for incremental updates |
pathlib.Path |
Resolving the script's directory to locate .env and sync-state.json relative to the script rather than the working directory |
zoneinfo |
Converts the Discord notification timestamp from UTC to the user's configured timezone (e.g. Europe/Prague). Standard library from Python 3.9+ (gracefully falls back to UTC on older versions). |
Now that the theory is behind us, let's set up the individual variables.
We will need to prepare variables. Here is a list of all the variables we will need to fill in (thank you for the summary, Claude):
| Variable | Required | Description |
|---|---|---|
NOTION_TOKEN |
Yes | Your Notion integration token |
GIT_BASE_URL |
Yes | Gitea API URL (e.g. https://gitea.example.com/api/v1) |
GIT_TOKEN |
Yes | Gitea API token with repo scope |
GIT_OWNER |
Yes | Gitea username or org that owns the repos |
AI_NAMING_ENABLED |
No | 0 to disable, 1 to enable AI image naming |
AI_API_URL |
No | Specify the full URL of the LLM (can be self-hosted as well), such as https://api.anthropic.com/v1/messages . |
AI_API_KEY |
No | Provide the API token for access to that LLM. |
AI_MODEL |
No | Specify the name of the language model, such as claude-haiku-4-5-20251001 |
DISCORD_LEVEL |
No | 0 = never, 1 = errors only, 2 = always |
DB1_NOTION_ID |
Yes | Notion database ID (32-char hex) |
DB1_GIT_REPO |
Yes | Target Gitea repository name |
DB1_LABEL |
Yes | Human-readable label for logs and Discord |
Timezone |
Yes | Provide your time zone, such as Europe/Prague or America/New_York . |
cp .env.example .env
nano .env
ntn_)https://www.notion.so/yourworkspace/abc123def456...?v=...
^^^^^^^^^^^^^^^
This is the database ID
Format it with dashes: abc123de-f456-... (or use as-is, the API accepts both).
https://www.notion.so/abc123def456...?v=...
^^^^^^^^^^^^
This is the database ID
main branch exists - if not, you can create it with a readme file). Making it ‘private’ should be an obvious setting. In my case, I created a couple:
notion-important-docsnotion-it-webdev-kbAll dropdown and find repository - choose Read and Write..env file including the names of the repos (without the .git extension) and then add the token as well (all of these variables are pre-created).Just add db3_notionDatabaseId, db3_giteaRepo, db3_label fields in the .env file. The script auto-detects up to 100 databases.
It is good to have realistic expectations as part of this script (inc. caveats related to the Notion API), namely:
{number}-{chapter}.{ext}) for the rest of that page to avoid burning API credits on a persistent error.sync-state.json file.image1.ext, image2.ext pattern.The exported files from Notion come in JSON and need to be converted back to Markdown. Here are the supported Notion Block Types:
| Block Type | Markdown Output |
|---|---|
| Paragraph | Plain text |
| Heading 1/2/3 | ## / ### / #### (shifted down one level) |
| Bulleted list | - item |
| Numbered list | 1. item |
| To-do | - [x] / - [ ] |
| Code | Fenced code blocks with language |
| Image | Downloaded to assets/, linked in md |
| File / PDF | Downloaded to attachments/, linked in md |
| Table | Markdown table |
| Quote | > blockquote |
| Callout | Blockquote with emoji |
| Toggle | <details><summary> |
| Divider | --- |
| Bookmark | Link with 🔗 |
| Embed / Video | Link |
| Column layout | Flattened to sequential content |
| Equation | $$expression$$ |
| Synced block | Content rendered inline |
Due to the generic image naming convention, I employed Haiku in recognizing what each image displays and name it accordingly, together with the chapter name + previous paragraph.
{sequential_number}-{chapter-slug}-{haiku-description}.{ext}
1-deploy-n8n-docker-n8n-web-registration-page.png
2-connect-n8n-with-claude-anthropic-api-dashboard.png
3-connect-n8n-with-claude-credential-form.png
4-connect-n8n-with-discord-webhook-settings.png
Three safety nets built in:
{number}-{chapter-slug}.{ext} for remaining images. Avoids burning credits on a persistent API issue.Cost estimate: Haiku vision is roughly $0.001-0.002 per image. Your first full sync (~1000-1500 images across all articles) would cost maybe $1-3. Weekly incremental runs would be pennies.
sudo touch /var/log/notion-gitea-backup.log
chown your_username:your_username /var/log/notion-gitea-backup.log
sudo nano /etc/logrotate.d/notion-gitea-backup
logrotate:/var/log/notion-gitea-backup.log {
weekly
rotate 4
compress
missingok
notifempty
}
.env.example to .env and filled it in with your keys, run a dry run on your first attempt (assuming you are using a path of /opt/notion-gitea-backup):python3 /opt/notion-gitea-backup/notion-gitea-backup.py --dry-run
python3 /opt/notion-gitea-backup/notion-gitea-backup.py 2>&1 | tee /var/log/notion-gitea-backup.log
vi /var/log/notion-gitea-backup.log (or use the tail command, such as tail -n 50 /var/log/notion-gitea-backup.log).crontab -e
# Notion backup every day at 7 AM
0 7 * * * /usr/bin/python3 /opt/notion-gitea-backup/notion-gitea-backup.py >> /var/log/notion-gitea-backup.log 2>&1
sync-state.json is in the same directory as the script and the user running it has write permissions.--full-sync parameter.I would encourage you to also take a look at the README.md file in the Github repo. Feel free to fork it and adapt it to your needs. Hopefully it helps a few people 😇