Themelio compatibility reference. This page describes the retained Themelio implementation or its historical design. Check the parallel-operation guide before applying it to the current platform.
Backup GitLab Repositories
Themelio stores all user-facing artifacts (models, datasets, spaces, repos, recipes, upskillings) as Git repositories on a GitLab instance, with binary content managed by Git LFS. Backing up the Git side of a Themelio deployment therefore reduces to mirroring every project on the source GitLab instance to a target GitLab instance. This page documents that procedure; GitLab-level metadata (issues, MRs, wiki, registries) and Themelio's non-GitLab state (Keycloak, Redis/Valkey, etc.) are out of scope here and need separate runbooks.
The repository ships a script that does exactly that:
It enumerates projects from the source instance, recreates the same group hierarchy under a configurable root group on the target, and pushes every ref, tag, and LFS blob across.
What gets transferred
Per project, the script transfers:
- Every ref — all branches and tags (
git push --mirrorsemantics). - All Git history — full commit graph, not a shallow clone.
- All Git LFS objects referenced anywhere in the repo (
git lfs fetch --allfollowed bygit lfs push --all). - Project metadata required to recreate the project on the target:
name,path,description, anddefault_branch.
What is not transferred:
- Issues, merge requests, milestones, labels, wiki, snippets, CI/CD variables, deploy keys, webhooks, runners, or pipeline history. The script is a Git-level mirror; if you also need GitLab-level metadata, use GitLab's project export/import API on top of this.
- Project members and permissions. Target projects are created as
privateunder the target root group; re-grant access manually. - Container or package registries.
How the script operates
At a high level, each invocation does the following:
- Validate inputs and dependencies. Required CLI tools (
git,git-lfs,curl,jq,awk,mktemp) are checked up front. Whitespace is trimmed from all token/URL/group inputs to avoid surprises from copy-paste orread-into-env-var workflows. - Validate the source token by calling
GET /api/v4/useragainst the source GitLab. The same is done for the target token unless the run is a dry-run with no target args (see below). - Resolve the target root group. The script does not create the root group; it must already exist on the target. The script does create any missing intermediate subgroups beneath it.
- Resolve the source repository list from
--source-repos. This accepts:*(or empty) — every project the source token can access.- Comma-separated entries, or a
@/path/to/list.txtfile with one entry per line (#comments allowed). - Each entry is either a full path (contains
/, e.g.models/foo/bar, matched exactly) or a keyword (no/, e.g.surya, matched as a case-insensitive substring against every accessible project'spath_with_namespace).
- Apply exclusions from
--exclude-reposusing the same full-path vs. keyword semantics. - Sort and de-duplicate the resolved list (
LC_ALL=C sort -u) so migration order is deterministic. - For each project, in sorted order:
- Look up source metadata; skip if archived (unless
--include-archived); skip if the target already has commits and--skip-existingis set. - Compute the target full path:
<target-root-group>/<source-path-with-namespace>. - Ensure all intermediate target subgroups exist; create the target
project if missing (
private,lfs_enabled: true, preserving name/description/default_branch). - Up-to-date short-circuit. Run
git ls-remote --heads --tagsagainst both source and target. If every ref on both sides has the exact same SHA, the target is already a perfect mirror of the source — record the project as up-to-date and skip the remaining clone/push steps entirely. Otherwise continue. git clone --mirrorthe source.git lfs fetch --all originto populate LFS blobs locally.git lfs push --all target— uploads LFS objects before refs. GitLab's pre-receive hook rejects refs whose LFS blobs are not already present on the target's LFS server, so this ordering is mandatory.git push --mirror targetto push every branch and tag.
- Look up source metadata; skip if archived (unless
- Print a summary of created groups, created/reused projects, mirrored projects, up-to-date projects, skipped projects, and failed projects. Exits non-zero if any project failed.
The script is idempotent: re-running reuses existing target groups and
projects. The up-to-date check (step 7.4) avoids any clone/push work
for repositories whose refs already match the source — only
repositories whose source has new or rewritten refs (or whose target
is missing some refs) actually pay the clone+LFS-fetch+push cost. Even
when transfer does happen, git push --mirror and git lfs push --all
are themselves incremental: Git's wire protocol negotiates the missing
delta and the LFS server only accepts blobs it does not already store.
Up-to-date short-circuit and re-run semantics
Before each project is mirrored, the script compares heads and tags on
both sides via git ls-remote --heads --tags:
- All refs match → the project is recorded as up-to-date and skipped without cloning. This makes scheduled re-runs cheap: only repositories with new commits, new tags, deleted refs, or rewritten history actually pay the clone/LFS-fetch/push cost.
- Refs differ, target has no refs, or target project was just created → fall through to the normal clone + LFS push + ref push flow.
- Source
ls-remotefails (network/auth error) → also fall through; the actual clone will surface the same error in a more useful place.
Important caveats:
- The check compares heads and tags, not arbitrary refs (e.g.
refs/keep-around/...). For backup purposes this matches every ref thatgit push --mirrorwould also touch. - Once the script does decide to push,
git push --mirroris force-push semantics: it rewrites target refs to match the source and deletes target refs that no longer exist on the source. Treat the target as a read-only backup destination — do not commit to it directly, or your changes will be silently overwritten on the next run. - The up-to-date check itself is read-only and uses the source/target
tokens you already provide. It runs whether
--dry-runis set or not; the only difference in dry-run is that the result is reported in the plan instead of acted on.
Required tools
Install on the host that will run the backup:
bash4+ (the script uses arrays and modern parameter expansion)gitandgit-lfs(rungit lfs installonce after installing)curljqawk,mktemp(typically pre-installed)
Required tokens
- Source token — a GitLab personal access token (or admin
impersonation token) on the source instance with at least the
read_apiscope plus read access to every project you want to mirror. An admin token mirrors everything; a regular user token only sees projects that user is a member of (or that are public/internal). - Target token — a personal access token on the target instance
with the
apiscope and at leastMaintainerrole on the target root group (so the script can create subgroups and projects beneath it).
Arguments
Every CLI flag has an environment variable equivalent. CLI flags take precedence over environment variables when both are set.
Required
In --dry-run mode the target arguments become optional — see below.
Optional
DEBUG=1 environment variable
Set DEBUG=1 in the environment to enable extra debug output (group
lookup responses, project create payloads, etc.). Useful when
diagnosing GitLab API issues.
Selection syntax
The --source-repos and --exclude-repos flags share the same syntax.
Each accepts one of:
*— every accessible project.- A comma-separated list of entries:
'a,b,c'. - A file reference prefixed with
@:@/path/to/list.txt. The file is read line-by-line; blank lines and lines starting with#are ignored.
Each individual entry is interpreted as either:
- A full path if it contains a
/— matched exactly against a project'spath_with_namespace. Example:models/foo/bar,themelio/demo-data/repos/pdf-tools. - A keyword if it does not contain a
/— matched as a case-insensitive substring against every accessible project'spath_with_namespace. Example:suryamatchesmodels/dkats/surya-cache,repos/dkats/surya,spaces/dkats/dkats-surya-pdf-app, etc.
Mixing is fine: --source-repos 'surya,dkats,themelio/special-repo'.
When applied to --exclude-repos, the same rules govern matching: a
keyword kathan drops every resolved project whose path contains
kathan, and a full path drops only its exact match.
Examples
Dry-run: list everything that would be mirrored
The output groups projects by their top-level prefix
(datasets/..., models/..., repos/..., spaces/...) and labels
each with its target path and one of:
target would be created— no project exists at the target path yet.target exists, would mirror updates— the target project exists but its refs differ from the source.- A separate
Up-to-date (would skip; refs already match source)section lists projects whose target is already an exact mirror of the source — re-running the script without--dry-runperforms no work for these.
Dry-run without target args (preview source list only)
This validates the source token and resolves the source list (with
keyword expansion) without touching any target instance — useful to
iterate on --source-repos / --exclude-repos before running the
actual transfer.
Mirror two users' worth of projects, excluding one space
Resolves all projects whose path contains dkats or louridas (case
insensitive), drops the explicitly listed space, then mirrors the rest
under themelio/dev_gitlab_backup/<source-path>.
Full backup of every accessible project
--source-repos defaults to *, so every project the source token
can see (excluding archived ones) is mirrored.
Backup from a curated list, skipping repos that already migrated
--skip-existing short-circuits projects whose target already has
commits, which is useful when iteratively expanding the backup or
re-running after a partial failure.
Re-running a failed or scheduled backup
The script is idempotent. After fixing the underlying issue (e.g. LFS disabled on a target group, missing permissions, expired token), simply re-run the same command. Existing groups/projects are reused, the up-to-date check skips repositories that did not change since the previous successful run, and Git push semantics ensure only diverged refs and missing LFS objects are re-transferred for the rest. This makes the script suitable for nightly cron jobs against a stable target instance.
Verifying a backup
After the script completes, confirm a sample of repositories on the target instance:
Troubleshooting
Failed to authenticate source token: invalid_token
GitLab is rejecting the token value the script sent. Common causes:
- Trailing whitespace/newline in the env var (the script trims
this since the most recent revision, but verify with
printenv GITLAB_SOURCE_TOKEN | wc -c— the byte count should be the token length plus 1 for the trailing newline added byprintenv). - Token expired or revoked — generate a new one in
User settings → Access tokens with
read_apiscope. - Wrong instance — tokens are per-instance.
Verify directly:
404 Group Not Found when creating an intermediate subgroup
The target token does not have permission to create subgroups beneath
the target root group, or the parent group reference is stale. Confirm
the token has at least Maintainer on the root group:
LFS objects are missing. Ensure LFS is properly set up
The target's pre-receive hook rejected the ref push because some LFS blobs were not on its LFS server. Make sure:
git lfs versionworks on the host running the script.- LFS is enabled on the target project (it is by default; the script
sets
lfs_enabled: trueat creation). - LFS storage is not full or quota-capped on the target instance — ask the target's GitLab administrator if unsure.
- The source token has read access to LFS objects (admin or owner on the source project).
If these are all in order and the issue persists for a specific repo,
re-run with DEBUG=1 to see the underlying git lfs fetch /
git lfs push errors.
Some projects show as target exists, would mirror in the dry-run, but I want a clean import
Either delete the target projects manually, or run without
--skip-existing — git push --mirror will overwrite the target's
refs to match the source. Use this with care: --mirror will also
delete target refs that no longer exist on the source.
Page source
Source snapshot · Revision f928834c8.