GitLab Read-Only Mode on a Free License
Locking 6,000 GitLab repos into read-only mode on a free license using the Rails console: fetching project IDs, excluding repos, and what behavior to expect.
We recently attempted to migrate our self-hosted GitLab instance from AWS to Tencent Cloud. The migration failed, and we had to roll back. But one thing that made the failure worse was that people kept pushing code to the old GitLab during the maintenance window. The VM snapshot was already taken at 21:00, but users were still committing code until 04:00. When we tried to bring up the new instance, those commits were just... gone from the disk.
So for the next attempt, we needed a way to block writes during the cutover. No pushes, no branch creation, no tag changes. Just keep everything readable but frozen.
The problem is our GitLab runs on a free license. GitLab has a built-in Maintenance Mode feature, but it requires a Premium subscription. We don't have that.
But here is the thing I found while digging through the docs. GitLab has a read-only state feature that is available on all tiers. It is not the same as Maintenance Mode, but it gets you most of the way there. You can set individual repositories to read-only, and it blocks all write operations while keeping everything readable.
How the migration failed (and why this matters)
A quick recap of what happened. We used go2tencentcloud to migrate the VM from AWS to Tencent, and DTS to sync the PostgreSQL database. The VM sync was stopped at 21:00, but the database sync kept running for another 6 hours. The VM snapshot was frozen at 21:00, but the database had records for commits that happened after that.
When the new instance came up, we saw errors like this:
ActiveRecord::RecordNotUnique (PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "authentication_events_pkey"
And then we found the real problem: commits pushed after 21:00 were missing from the new instance. The repositories on disk did not have the latest data, but the database expected them to exist.
We ran a script on the old AWS instance to confirm:
find /var/opt/gitlab/git-data/repositories \
-path "*/+gitaly/state/*" -prune -o \
-name "*.git" -print | while read repo; do
git --git-dir="$repo" rev-parse HEAD >/dev/null 2>&1 || continue
repo_name=${repo#/var/opt/gitlab/git-data/repositories/}
repo_name=${repo_name%.git}
git --git-dir="$repo" log \
--since="2026-07-31 21:00:00" \
--until="2026-08-01 05:00:00" \
--pretty=format:"$repo_name | %ad | %an | %h | %s" \
--date=iso
done
The output was a long list of commits from multiple users across different projects, all pushed during the window when we thought everything was frozen. Some were automated deployments from CI pipelines. Others were manual commits from developers working late. All of them were missing from the new instance.
So yeah. We needed a way to actually prevent writes next time.
Finding the read-only feature
I went through the GitLab docs and found the read-only state page. It describes how to set repositories as read-only using the Rails console. The feature is available on all license tiers, including free.
The key is the repository_read_only attribute on the Project model. Set it to true, and the repository blocks all write operations. Set it back to false, and everything goes back to normal.
The docs actually show how to set all projects to read-only with Project.all.find_each. But in my case, I could not just lock everything. We have two configuration repositories that are consumed by production services. I just did not want to take any risk by touching them. So I needed to lock all 6,150 projects except those two.
Getting all project IDs
First, I needed a list of every project ID. The GitLab API has a /projects endpoint that returns paginated results. I wrote a small Python script to fetch all of them and export to CSV.
The script is pretty simple. It paginates through the API, collects all project info, and writes two output files: a full CSV with all project metadata, and a convenience file with just the IDs in a comma-separated list.
# Core loop of the script
while True:
url = f"{base_url}/projects?page={page}&per_page=100"
params = {"membership": False, "simple": True}
response = api_get(url, params=params)
data = response.json()
if not data:
break
projects.extend(data)
page += 1
I ran into a couple of issues with this. First, the API timed out on page 4 with a 30-second timeout. I bumped it to 120 seconds and added retry logic with exponential backoff. Second, my admin user's personal access token was not returning all projects. The token needed "Allow admin mode" enabled in GitLab's token settings. Without it, the API only returned projects where the user was a direct member. That was a fun 30 minutes of confusion.
Once that was sorted, the script ran and gave me a CSV with all 6,150 projects. I also had a _ids_only.txt file with a comma-separated list of IDs, ready to paste into the Rails console.
Excluding the two configuration repos
I already mentioned this, but the two repos we needed to exclude are configuration repositories consumed by production services. Their project IDs are 7052 and 5607. I just did not want to take any risk by touching them.
I filtered them out of the ID list. The final list went into the Rails console command.
Enabling read-only mode
Here is the actual command I ran. Open the Rails console:
sudo gitlab-rails console
Then paste the project IDs:
# List of project IDs to set to read-only.
projects = [1, 2, 3, ...] # 6,148 IDs here (6,150 total minus 2 excluded)
projects.each do |p|
project = Project.find(p)
project.update!(repository_read_only: true)
rescue ActiveRecord::RecordNotFound
puts "Project ID #{p} not found"
end
The rescue block is there because some project IDs might have been deleted between the time the script ran and the time you run this command. It is a safety net.
We still had the CVM from the failed migration sitting around unused, so I used it for this POC. It took about 5 minutes to process all 6,148 projects. Not bad at all.
Restoring write access
When the maintenance window is over, you just flip the flag back:
Project.all.find_each { |project| project.update!(repository_read_only: false) }
This also took about 5 minutes. The find_each method processes records in batches of 1,000, which is a lot more efficient than loading all projects into memory at once.
What actually happens when a repo is read-only
This is the part where I learned the difference between "read-only" and "archived." These are not the same thing.
When you set repository_read_only: true, the repository is not archived. There is no visual indicator in the GitLab UI. No banner, no badge, no notification. The project looks completely normal. Users can browse the code, view branches, check commit history, look at pipeline results. Everything looks fine.
They only discover the restriction when they try to write something.
Here is what is blocked:
- Editing files via the web UI. You get an error:
13:update reference: running pre-receive hooks: GitLab: The repository is temporarily read-only. Please try again later. - Pushing from the terminal. Same error:
The repository is temporarily read-only. Please try again later. - Creating a branch.
Failed to create branch: 13:running pre-receive hooks: GitLab: The repository is temporarily read-only. Please try again later. - Approving a merge request.
Something went wrong during merge pre-receive hook. The repository is temporarily read-only. Please try again later.But you can still close the merge request, which is interesting. - Creating or removing tags. Both fail with variations of the read-only error.
- Creating an issue. I got an HTTP 500 with
Response not successful: Received status code 500. Honestly, I am not sure if this is caused by the read-only mode or by the data corruption from the failed migration. The CVM I used for testing was the same one from the rolled-back migration, so it had some database issues. I did not have a clean instance to isolate this. - Editing wiki pages. Also blocked.
- Updating labels. Blocked.
- Transferring project path. Gives
Project could not be updated!. Changing the project name and description still works though.
And here is what still works:
- Browsing the repo in the UI (code, branches, commits, pipeline history)
- Pulling, fetching, and cloning via HTTPS or SSH
- Closing merge requests
- Sending test webhooks and resending from webhook history
- Changing project name and description
The error message for Git operations is consistent: "The repository is temporarily read-only. Please try again later." It is clear enough that users will understand what is happening, even if they were not notified beforehand. But the non-Git errors are inconsistent. Some give HTTP 500, some give generic error messages. Users might not know what is going on.
The limitations
The read-only flag blocks more than I expected. It is not just Git push operations. It also blocks issues, wikis, labels, and some project settings. That is actually a good thing for our use case. We want the instance to be as frozen as possible during the migration window.
The bigger concern is the inconsistent error messages. When a user tries to create an issue and gets a raw HTTP 500, they are not going to think "oh, the repo is read-only." They are going to think something is broken. Combined with the fact that there is no visual indicator of read-only mode in the UI, this is confusing.
Pipeline behavior is also uncertain. When I tried to create a new pipeline from the console during read-only mode, I got Pipeline cannot be run. The error message is too generic to know if this is caused by the read-only setting or something else. I did not have time to dig deeper into this. For our use case, it does not matter much since the maintenance window is short and we do not expect many pipeline runs during that time.