Why leave GitLab.com for a self-managed instance
When the pricing of a SaaS tool shifts, like the recent Atlassian Cloud increase covered in our article on the double price shock, teams re-examine their whole development toolchain. For an agency the question is concrete: the code of ten clients lives on a single account, at a third party, with retention and access rules you do not set yourself.
A self-managed instance moves those decisions to you: where repositories are stored, how long artifacts are kept, who can sign in, and what it costs each month. The price of that freedom is a migration job that GitLab does not fully do for you.
This guide does not repeat the choice of forge, covered in the forge comparison, nor the installation, described in the GitLab CE installation guide. It starts from a GitLab instance that is already online and only covers the move from GitLab.com to it.
What you gain by taking hosting back
- Access under your control — accounts, groups and roles live on your instance, not on a SaaS account shared between clients.
- Retention you decide — how long artifacts and logs are kept and how large the registry grows are all set in the administration area.
- A bill that follows your VPS — the cost depends on the machine you size, not on a seat count.
- A domain name of your own — the repository answers on
git.your-domain.com, with your certificate and your firewall. - A step-by-step migration — the old project stays in place, read-only, until the new one is validated.
What the migration does not carry, to inventory first
The official documentation for project export lists what is not exported: child pipeline history, pipeline triggers, CI/CD job traces and artifacts, container and package registry images, CI/CD variables, webhooks, encrypted tokens, the number of required approvals, deploy keys allowed to push to protected branches, secure files and pipeline schedule variables. Deploy keys are not imported either: you must enable them again in the imported project.
Direct transfer has its own exclusion list, which overlaps with the first: CI/CD variables, webhooks, container registry images, the package registry, job logs and job artifacts. At group level, CI/CD variables, deploy tokens and push rules do not follow either.
Write this inventory in a table before you start: one row per project, one column per missing item, one column for the person who rebuilds it. This document, more than the export itself, protects you the day the old access closes.
Project export or direct transfer: what each method covers
Scroll the table
| Item | Project export (file) | Direct transfer |
|---|---|---|
| Granularity | One project at a time, through a downloaded file | Whole groups, with or without their projects, from the interface |
| CI/CD variables | Not exported | Not migrated |
| Container registry images | Not exported | Not migrated |
| Webhooks | Not exported | Not migrated |
| Job logs and artifacts | Not exported | Not migrated |
| Members | Matched if the source public email equals the destination primary email | Matched if the source public email equals a confirmed destination email |
| What the instance needs | An administrator performs the import | Feature enabled by an administrator, token with the `api` scope on the source |
Prerequisites: versions, permissions and instance resources
First rule, the version. A file import accepts exports from a version up to two minor versions behind the destination, and the documentation asks that the destination be at least at the source version. For direct transfer, the documentation recommends GitLab 16.8 or later on both sides, and asks that a source be no more than two minor versions behind the destination. GitLab.com moves continuously: aim for the latest stable release on your instance, update it before migrating, not after, and reread the accepted gap in the documentation on the day.
On permissions, you need the Owner role on the source group, an administrator on the target instance, and a personal access token with the api scope created on GitLab.com. The two instances must reach each other over HTTPS, with free space in /tmp.
On resources, our editorial recommendation: 4 vCPU and 8 GB of RAM for an instance that also hosts the registry, an NVMe disk sized to the combined size of repositories and images, and runners on a separate VPS as soon as builds get heavy. The installation itself is described in the GitLab CE on VPS guide.
Plan and run the migration, from inventory to switchover
Inventory groups, projects and dependencies
List every group, every project, its variables, webhooks, runners and integrations. Note who owns each one on the client side.
Update the target instance
Check the version in the administration area and aim for the latest stable release, at least equal to GitLab.com, before any import.
Create users on the target
Members are not created during migration. Create accounts beforehand, with the primary email each person uses on GitLab.com.
Plan one group per client
Create on the target one group or subgroup per client, with minimal permissions, before importing.
Have public emails published on the source
Each member sets a public email in their GitLab.com profile, at least until the import is done.
Enable direct transfer on the target instance
An administrator enables group migration by direct transfer in the application settings. It is off by default on a self-managed instance.
Start the group import
On the target, choose Create new then New group then Import group, enter
https://gitlab.comand your token, then Connect instance. Select the groups with Import with projects.Export standalone projects by hand
For a project outside a group, open Settings then General then Advanced then Export project, download the file and import it on the target.
Follow the import and read the errors
The Status column shows progress. On a failed or partial import, Show errors details the cause. Allow several hours for a large number of projects.
Hook up the registry and CI
Rebuild variables and images as described below, then run a full pipeline on the target before touching the old project.
Members and assignees: the matching rule
On a self-managed instance, the published rule is this: users must already exist on the destination, and the public email of the source account must match the primary email of the destination account. If the conditions are met, the person is added as a direct member. Otherwise, the association changes to the user performing the import: your comment authors and assignees all end up under your name.
Before importing, check the Public email field on every GitLab.com profile. For a project export, you can also add public emails by editing the project_members.ndjson file in the archive. The owner of the top-level group must run the export so that direct and inherited members are included, and an administrator must perform the import.
With direct transfer, imported memberships are first associated with placeholder users, pending reassignment. An Import user memberships checkbox also lets you skip importing memberships. In that case, the source public email against a confirmed destination email decides the match, not the primary email as with a file import.
For an agency, structure the target before importing: one top-level group per client, or one subgroup per client under an agency group. Give each client the minimal role on its own group only (Reporter or Developer rather than Owner), keep the Owner role for the internal team, and you can later move a client out by transferring its group or exporting its projects, without touching the others.
CI/CD variables and container registry: rebuild by hand
Variables do not travel, because GitLab treats them as sensitive. List them on the source with the API: curl --header "PRIVATE-TOKEN: <token>" "https://gitlab.com/api/v4/projects/<id>/variables". The list gives the key, the masked and protected attributes and, usually, the value; but not every value is necessarily readable, depending on the version and how the variable was created.
Then recreate them on the target with curl --request POST --header "PRIVATE-TOKEN: <token>" --form "key=NAME" --form "value=value" "https://git.your-domain.com/api/v4/projects/<id>/variables". The key accepts 255 characters among A-Z, a-z, 0-9 and _. When the value is missing or stays hidden, recreate the secret at its provider and store it in the agency vault.
Group variables are recreated from the group's CI/CD settings. Runners are registered again on the new instance: their tokens do not follow.
Move registry images to the new URL
Enable the registry on the target instance
In
/etc/gitlab/gitlab.rb, setregistry_external_urlover HTTPS, for examplehttps://registry.your-domain.com, then runsudo gitlab-ctl reconfigure. The registry requires a TLS certificate.Create a token for each side
A token with
read_registryis enough to pull images, andwrite_registryas well to push them.Pull each image from the old URL
Sign in with
docker login registry.gitlab.com, thendocker pull registry.gitlab.com/group/project/image:tag.Retag, then push to the target
Run
docker tag registry.gitlab.com/group/project/image:tag registry.your-domain.com/group/project/image:tag, thendocker login registry.your-domain.comanddocker push registry.your-domain.com/group/project/image:tag. Registry repository paths are written in lowercase.
Keep the way back open
Delete nothing on the source. Archive the old project or remove write access, keep it readable, then run a pipeline and a full git clone from the target. As long as the client has not signed off on validation, the switchover stays reversible: you only need to reopen the old project.
Troubleshooting: authentication and import errors
On docker login or docker push, the message access forbidden means, according to the documentation, that the Authorization header was stripped before reaching GitLab, often by a proxy in front of the instance. Check the reverse proxy configuration.
An UNAUTHORIZED (401) error points to wrong or expired credentials, missing scopes, or a password used with two-factor authentication enabled: use a token instead. A DENIED (403) means the credentials are valid but have no right on the registry.
On a large image, unauthorized: authentication required signals a token that expired before the push finished. An administrator can lengthen its lifetime.
On the import side, a milestone title that already exists gets a unique suffix such as 18.0 (imported-…): rename the milestone before migrating to avoid it.
Checks before you cut off the old access
- Identical repositories — compare branches and tags, then do a full clone from the target.
- Full green pipeline — a pipeline on the main branch with variables recreated and runners registered again.
- Images present — every tag used by a deployment exists in the new registry.
- Members and roles reviewed — no author or assignee has moved to the account that started the import.
- Webhooks and deploy keys recreated — chat, deployment and monitoring integrations included.
- Written sign-off from the client — the old project only closes after that agreement.
The official documentation
The rules in this guide come from the GitLab documentation: project export and import, group migration by direct transfer, migrated items, project variables API, registry authentication and registry on a self-managed instance. Reread them on the date of your migration: the lists of migrated items evolve.
Next: consolidate around your instance
Once the projects are validated, the instance becomes the central point of the agency's portfolio. For a heavier registry, Harbor on a VPS can take over. If your migration comes from another ticketing tool, the Jira to OpenProject or Plane guide follows the same inventory logic. And if you are still unsure about the forge, moving from GitLab to Gitea remains a lighter option.