# Backup Notion DB to Gitea using Python [TOC] ## Introduction 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! ### Pre-requisites Here are the tools and requirements to make this effort happen: - **Notion** (API token) - **Gitea** (or similar source version control software) with an empty repo per Notion resource you want to back up. - **Optional: Anthropic API key** (for more customized image naming) - **Discord webhook** (optional but recommended) - A **runtime environment** to run a Python script (such as a simple Fedora / Debian / Ubuntu VM or container). 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](https://www.pillar.security/blog/n8n-sandbox-escape-critical-vulnerabilities-in-n8n-exposes-hundreds-of-thousands-of-enterprise-ai-systems-to-complete-takeover) (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! ## 1. Import the Script You can `git clone` the script from a [Github repo](https://github.com/lbakyl/backup-notion-to-git/tree/main). It is written in pure Python with no external dependencies. The following internal libraries are used: ### The Script Explained When the script runs, it follows the following pipeline: **load configuration → query Notion → convert to markdown → commit to Git → notify**. More precisely: - On startup, the script reads the `.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. - For each configured database, the script queries the Notion API to get all pages, then compares each page's `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. - For each changed page, the script fetches every block (paragraphs, headings, images, code blocks, tables - 30+ types) and converts them into clean markdown (this is because docs fetched from Notion API arrive in JSON). Images are downloaded, their actual format is detected from the raw bytes (because Notion's URLs often have misleading extensions), and they're base64-encoded for the Git API. Attachments like PDFs and JSON files go through the same process. - Before committing, the script flushes any existing files in that page's `assets/` and `attachments/` directories in the Git repo. This prevents orphaned files from piling up when images are added, removed, or renamed between syncs. - The converted markdown, images, and attachments are then committed to the Gitea repository via its API - one file at a time, each as its own commit. After each page is successfully synced, the script saves the sync state immediately, so if the process is interrupted (network failure, Ctrl+C, server restart), it picks up where it left off on the next run. - Once all databases are processed, the script sends a summary to Discord (if configured) showing how many pages were synced, how many files were committed, and any errors that occurred - along with the hostname of the machine that ran it. - The script exits with code `0` on success or `1` if any errors occurred, making it easy to monitor via cron or any process supervisor. ### Libraries used (for geeks) 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. ## 2. Create API tokens 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`](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` . | - Copy over the .env.example file into .env and start filling it in using the steps below: ```bash cp .env.example .env nano .env ``` ### Create Notion Integration - Go to [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations) - Click on the ‘**+ New integration**’ button - Name it (e.g. ‘Gitea Backup’) - Type: Internal - Select your workspace and click on the ‘Create’ button.

1 create notion integration

- A popup will appear - click on the ‘Configure Integration settings’ button. - Copy the **Internal Integration Secret** (starts with `ntn_`) - In the ‘Capabilities’ section, ensure **Read content** is enabled (no update / insert content permissions are needed) ### Share Notion Databases with Integration - Open each database in Notion (such ’Important Documents to Keep’). - Click the **⋯** menu (top right) → **Connections** → select your integration. - Confirm it.

2 share notion databases with

### Get Notion Database IDs - From each database page URL: ``` 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). - Alternatively, if you are on the free subscription within just one Workspace, you can find the DB name divided by a question mark: ``` https://www.notion.so/abc123def456...?v=... ^^^^^^^^^^^^ This is the database ID ``` ### Create Discord Webhook (Optional) - In your Discord server → channel settings → **Integrations** → **Webhooks** - Create webhook, copy the URL

3 create discord webhook

### Create Gitea Repos & Token - In Gitea, create one empty repo for each Notion resource (ensure the `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-docs` - `notion-it-webdev-kb` - Generate a Gitea API token (it is good to have a dedicate one for this purpose): - **Settings** → **Applications** → **Generate New Token** - Permissions needed: Expand on the `All` dropdown and find `repository` - choose `Read and Write`.

4 create gitea repos token

- Enter the Gitea server URL into the `.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). ### Create an Anthropic token - Go to [https://platform.claude.com/settings/keys](https://platform.claude.com/settings/keys) and click on the ‘**+ Create Key**’ button. - Fill in a memorable name and click on the ‘Add’ button to generate a new token (ideally, save it in your favorite password manager like [**Vaultwarden**](https://bachelor-tech.com/detailed-guides/deploy-vaultwarden-in-multi-site-environment-in-ha-docker-opnsense-galera-cluster-nginx/)).

5 create an anthropic token

### Having more databases to back up? Just add `db3_notionDatabaseId`, `db3_giteaRepo`, `db3_label` fields in the `.env` file. The script auto-detects up to 100 databases. ## 3. Behavioral Notes It is good to have realistic expectations as part of this script (inc. caveats related to the Notion API), namely: - **Image re-download on page edit**: Any edit to a page triggers re-download of all its images. Notion's API doesn't expose block-level change tracking, and signed image URLs change on every API call, making URL-based diffing impossible. This is wasteful but unavoidable without a local block-hash cache. - **AI naming circuit breaker**: If Haiku fails 5 times consecutively within a page, the script switches to fallback naming (`{number}-{chapter}.{ext}`) for the rest of that page to avoid burning API credits on a persistent error. - **Incremental sync**: Only pages modified since last successful run are processed. The timestamp is stored in the `sync-state.json` file. - **Rate limiting**: Built-in 340ms delay between Notion API calls (~3/sec) and 150ms between Gitea commits. - **Image expiry**: Notion's signed S3 URLs expire within ~1 hour. Images are downloaded during the same run and renamed to avoid the default `image1.ext`, `image2.ext` pattern. - **Error handling**: Individual page failures do not stop the script’s execution. Errors are collected and reported in the Discord summary. ### Conversion of blocks (JSON → .MD) 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 | `
` | | Divider | `---` | | Bookmark | Link with 🔗 | | Embed / Video | Link | | Column layout | Flattened to sequential content | | Equation | `$$expression$$` | | Synced block | Content rendered inline | ### AI Naming Workflow 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. - The script sends Haiku the image itself (base64 via vision) plus two pieces of context: the current heading and the preceding paragraph text. Haiku returns a short descriptive slug, which gets assembled into the final filename: ```bash {sequential_number}-{chapter-slug}-{haiku-description}.{ext} ``` - An example set from one of my previous tutorials: ```bash 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:** 1. **Circuit breaker:** if Haiku fails 5 times in a row on a page, it stops trying and falls back to `{number}-{chapter-slug}.{ext}` for remaining images. Avoids burning credits on a persistent API issue. 2. **Response validation:** if Haiku returns garbage (too short, too long, empty), it falls back gracefully. 3. **No API key = no problem:** if you leave the Anthropic key blank, it silently uses the chapter-based fallback for everything **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. ## 4. Testing & Troubleshooting Executions - Firstly, let’s prepare a log file so that we can track it. Then we can use logrotate (or whatever you use to maintain the length of your logs) to archive them regularly. ```bash 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 ``` - Recommended standard settings for `logrotate`: ```bash /var/log/notion-gitea-backup.log { weekly rotate 4 compress missingok notifempty } ``` - Once you have copied over `.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`): ```bash python3 /opt/notion-gitea-backup/notion-gitea-backup.py --dry-run ``` - Run the script fully with a log: ```bash python3 /opt/notion-gitea-backup/notion-gitea-backup.py 2>&1 | tee /var/log/notion-gitea-backup.log ```

6 4 testing troubleshooting

- The first run will sync ALL pages (since there's no previous timestamp). - Check your Gitea repos and Discord for results. You can also check the log file, e.g. `vi /var/log/notion-gitea-backup.log` (or use the tail command, such as `tail -n 50 /var/log/notion-gitea-backup.log`).

7 4 testing troubleshooting

### Set Up a Cron Job - Once you know it works for you, set up a cron job. On Debian/Ubuntu, this could look as follows (to run every day at 7am): ```bash 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 ``` ### Troubleshooting the Script - **"Unauthorized" from Notion API** → Check your token in Config. Ensure the integration is connected to both databases (Step 2). - **"404" from Gitea API** → Verify the repo names match exactly. Ensure repos are initialized (not empty). - **Images missing / broken links in markdown** → Check Discord summary for download errors. Notion's temporary URLs may have expired if the workflow took too long (unlikely for 60-80 pages). - **State not persisting between runs** → Ensure `sync-state.json` is in the same directory as the script and the user running it has write permissions. - Lots of changes in your notes and you want to start afresh → run the script with the `--full-sync` parameter. I would encourage you to also take a look at the [**README.md**](https://github.com/lbakyl/backup-notion-to-git/blob/main/README.md) file in the Github repo. Feel free to fork it and adapt it to your needs. Hopefully it helps a few people 😇