Using the GitLab Rails Console: Practical Examples for Managing Container Registry

While the GitLab UI and API are the standard tools for day-to-day operations, they often hit a wall when dealing with massive datasets. API rate limits can slow down bulk cleanup tasks, and the UI is ineffective for managing thousands of objects.

In such situations, we sometimes need direct, fast access to GitLab’s internal data. This is where the GitLab Rails console comes in. Built on the Ruby on Rails framework, the Rails console provides a command-line interface for interacting directly with GitLab’s application models using the Ruby language.

The Rails console is fast and flexible, but also unforgiving: a single command can modify or delete production data instantly.

It is strongly recommended to test all commands in a staging or test environment before executing them in production, and to ensure data backups are in place. Check out this post to run the GitLab using Docker/Podman locally.

At the core of the GitLab Rails console is ActiveRecord, the Object–Relational Mapping (ORM) layer provided by Ruby on Rails. ActiveRecord acts as a bridge between Ruby objects and database tables, allowing to work with database records using plain Ruby code instead of raw SQL (“just a wrapper”).

Basics

Run the rails console on your GitLab server:

sudo gitlab-rails console

Once loaded, enable the debug mode:

ActiveRecord::Base.logger = Logger.new($stdout)

Consider the following command:

prj = Project.find_by_full_path(test/test2)
  1. Project is a model mapped to the projects database table
  2. find_by_full_path is a model method that builds a SQL query using the provided project path (try to type Project. and press tab to initiate autocompletion, the console will display all available methods for the Project model)
  3. prj is a variable to store Ruby’s object or nil (if no project found)

Project.methods.grep(/find/) will show methods available to Project model containing find word. Use <model>.methods and <object>.attributes to get all available methods and properties of object (e.g. prj.attributes and project.methods)

Next, if we run prj.container_repositories we will see actual SQL query used by the code, so we are interacting with the container_repositories table

 D, [2025-12-24T09:47:41.526263 #4889] DEBUG -- : ContainerRepository Load (1.8ms) /*application:console,db_config_database:gitlabhq_production,db_config_name:main,console_hostname:gitlab.example.com*/ SELECT "container_repositories".* FROM "container_repositories" WHERE "container_repositories"."project_id" = 1 /* loading for pp */ LIMIT 11

Consider you need to find all projects with at least one tag in the repo

count = 0
Project.find_each do |project|
  if project.has_container_registry_tags? 
    count += 1
  end
end
puts "Total projects: #{count} "

and result is Total projects: 2567

Thousands of projects in the GitLab were “queried” in just a few seconds. That’s a magic of GitLab Rails console that API doesn’t have.

Some explanations:

  • count = 0 simply a local variable; our counter
  • Project.find_each loads projects in batches
  • do | project | starts a loop and assigns each project to the variable project
  • if project.has_container_registry_tags? main logic, clear enough to any
  • count += 1 increments our counter
  • puts "Total projects: #{count}" prints result once the loop is finished

If you need to show project paths and print projects with image tags:

count = 0
Project.find_each do |project|
  puts project.full_path
  if project.has_container_registry_tags?
    puts "  -> Project has container registry tags"
    count += 1
   end
 end
puts "Total projects: #{count} "

Result:

test/project1
test/test2
-> Project has container registry tags
test2/test2

Try out different models such as User, Group, Ci::Pipeline and others

Zombies

An interesting behavior to note is that destroying a repository object via repo.destroy! does not automatically purge the physical data from the storage backend. If the Container Registry is active, GitLab’s background processes or a simple page refresh may trigger a “re-sync.”

  • You run repo.destroy! on repository ID 16.
  • The database record is deleted, but the physical tags remain in storage.
  • GitLab detects the existing data at that path and automatically creates a new record in a database
  • A new repository appears with a new ID 17, containing all the old tags.

To prevent this “zombie” effect, you should always delete the tags through the GitLab API or Rails methods before destroying the repository record, and follow up with a Registry Garbage Collection to reclaim the disk space

gitlab-ctl registry-garbage-collect -m
#removes untagged manifests and unreferenced layers as well
#wont'work if the container registry is disabled

You can write a simple command to delete the repo and tags in the test/test2 project (DANGER):

prj = Project.find_by_full_path("test/test2")
  prj.container_repositories.each do |repo|
    repo.delete_tags!
    repo.destroy!
end

delete_tags and has_container_registry_tags? methods require availability of GitLab Container Registry. Otherwise, the following error shown “Failed to open TCP connection to localhost:5000 (Connection refused – connect(2) for “localhost” port 5000) (Faraday::ConnectionFailed)”

Other examples

To delete image tags and container registry repositories (DANGER):

Project.find_each do |p|
    next unless p.has_container_registry_tags?
    p.container_repositories.each do |repo|
      puts "Cleaning tags for #{p.full_path}"
       repo.delete_tags!
      puts ":::Destroying the repo for #{p.full_path}"
       repo.destroy!
    end
end

next unless can be read as “if repository doesn’t have any container registry tags, skip it and find relevant projects“; works faster and looks cleaner

If your Container registry has been disabled and you need to clean container registry repositories:

  • temporary enable the container registry service (recommended) OR
  • destroy registry repositories, and then manually clean file storage (based on my testing, we need to interact with  /var/opt/gitlab/gitlab-rails/shared/registry/docker/registry/v2/ after deleting registry repos via GitLab rails , and likely it doesn’t break anything; blobs and repositories folders will be created automatically once you pushed any images again)
  • Anyway, do you own research before any change!

To find projects with container registry repo when registry has been disabled:

count = 0
Project.find_each do |project|
  if project.container_repositories.count > 0
    puts "  -> Project #{project.name} has container repos"
    count += 1
   end
 end
puts "Total projects: #{count} "

To delete container registry repos when registry has been disabled (DANGER):

Project.find_each do |p|
    next unless p.container_repositories.count > 0
    p.container_repositories.each do |repo|
      puts ":::Destroying the repo #{repo.name} for #{p.full_path}"
       repo.destroy!
    end
end

Use case: deleting registry repositories while registry is disabled will help you to transfer a project (if you need). Otherwise, GitLab won’t allow you to change the path or namespace of any project.

While the scripts above are powerful, they are also permanent. Treat the Rails console like a sharp blade: incredibly useful, but dangerous if handled carelessly. And don’t forget these rules 1. Do Your Own Research. 2. Audit with puts before execution of harmful queries (destroy and other methods) . 3. Test every query on a single project before running a batch.

Why GitLab Fails with “Operation Not Permitted” on Windows Using Podman

If you run GitLab (or any application that modifies file permissions or ownership of files in volume mounts) in a container, you may see the installation fail with an error like:

chgrp: changing group of '/var/opt/gitlab/git-data/repositories': Operation not permitted

This error prevents GitLab from starting. Here’s why it happens—and the simplest way to fix it.

A local GitLab installation was required to troubleshoot and verify several production-critical queries. This setup is clearly not intended for production use and should be used only for testing and troubleshooting purposes.

Also, Windows is not officially supported as the images have known compatibility issues with volume permissions and potentially other unknown issues (although, I haven’t noticed any issues during a week)

Both Podman Desktop and Docker Desktop run containers by using WSL2

The problem appears when you bind-mount a Windows directory (NTFS) into the container, for example:

E:\volumes\gitlab\data → /var/opt/gitlab
podman run --detach --hostname gitlab.example.com `
--env GITLAB_OMNIBUS_CONFIG="external_url 'http://gitlab.example.com'" `
--publish 443:443 --publish 80:80 --publish 22:22 ` 
--name gitlab --restart always `  
--volume /e/volumes/gitlab/config:/etc/gitlab `
--volume /e/volumes/gitlab/logs:/var/log/gitlab `
--volume /e/volumes/gitlab/data:/var/opt/gitlab `
gitlab/gitlab-ce:18.5.4-ce.0

The same command works fine with Docker Desktop (E is an external disk drive available to Windows host)

What goes wrong

So far, we have the following flow:

  • GitLab requires real Linux filesystem permissions and ownership
  • During startup, it runs chown and chgrp on its data directories
  • Windows filesystems (NTFS) do not support Linux UID/GID ownership
  • WSL2 cannot translate these permission changes correctly
  • The operation fails, and GitLab refuses to start

If both Podman and Docker are based on WSL2, why does Docker run GitLab on an E: drive without breaking a sweat? The root cause is the difference in how Docker and Podman translate file permissions.

Docker: if GitLab calls chgrp, WSL’s drvfs layer intercepts the call. It doesn’t actually change the Windows folder, but it records the “permission change” in a hidden metadata area (NTFS Extended Attributes).

/etc/wsl.conf content of the docker desktop engine:

[automount]
root = /mnt/host
options = "metadata"
[interop]
enabled = true

When metadata is enabled as a mount option in WSL, extended attributes on Windows NT files can be added and interpreted to supply Linux file system permissions.

Podman: mounts Windows drives using the standard WSL2 9p protocol and drvfs driver (as Docker actually) without the complex metadata mapping enabled by default. When GitLab/your app tries to set its required ownership, the mount simply refuses, causing the container to crash

Here is an output for E disk drive mount from the podman machine:

mount | grep " /mnt/e "
E:\ on /mnt/e type 9p (rw,noatime,aname=drvfs;path=E:\;uid=1000;gid=1000;symlinkroot=/mnt/,cache=5,access=client,msize=65536,trans=fd,rfd=5,wfd=5)

there is no metadata option for the mount because of such simple wsl.conf:

[user]
default=user

Solution

The easiest solution here is to use named volumes (universal and faster) or a bind mount (if Docker is used; slower); custom wsl.conf and bind mount (if Podman is used; slower)

Named volumes:

podman run --detach --hostname gitlab.example.com `
--env GITLAB_OMNIBUS_CONFIG="external_url 'http://gitlab.example.com'" ` 
--publish 443:443 --publish 80:80 --publish 22:22 ` 
--name gitlab --restart always ` 
--volume gitlab-config:/etc/gitlab `
--volume gitlab-logs:/var/log/gitlab ` 
--volume gitlab-data:/var/opt/gitlab `
gitlab/gitlab-ce:18.5.4-ce.0

and the data will be stored at /var/lib/containers/storage/volumes (podman machine in this example):

Can be accessed from Windows Explorer as well:

  • Docker: \\wsl$\docker-desktop\mnt\docker-desktop-disk\data\docker\volumes
  • Podman: \\wsl$\podman-machine-default\var\lib\containers\storage\volumes

Bind mounts:

docker run --detach `
  --hostname gitlab.example.com `
  --publish 443:443 --publish 80:80 --publish 22:22 `
  --name gitlab-bind-mount `
  --restart always `
  --volume /e/volumes/gitlab/config:/etc/gitlab `
  --volume /e/volumes/gitlab/logs:/var/log/gitlab `
  --volume /e/volumes/gitlab/data:/var/opt/gitlab `
  gitlab/gitlab-ce:18.5.4-ce.0

Custom wsl.conf (podman):

[automount]
options = "metadata"

[user]
default=user

[interop] enabled=true is not actually required since it’s true by default, then restart podman and try podman run again


Docker and Podman use different WSL default configurations. Docker tolerates emulated ownership changes by enabling the metadata option out of the box.

Podman, on the other hand, does not rely on this metadata and expects real Linux filesystem behavior. It is also daemonless and lighter than Docker—but that’s a story for another blog post.