diff --git a/gems/gitlab-backup-cli/.rubocop.yml b/gems/gitlab-backup-cli/.rubocop.yml
index b67751b139cb45453d8532c6375acce3fef4272f..11e619c2b042b61ddee330596533bc100529766f 100644
--- a/gems/gitlab-backup-cli/.rubocop.yml
+++ b/gems/gitlab-backup-cli/.rubocop.yml
@@ -11,3 +11,6 @@ Rails/Exit:
 RSpec/MultipleMemoizedHelpers:
   Max: 25
   AllowSubject: true
+
+Rails/RakeEnvironment:
+  Enabled: false
diff --git a/gems/gitlab-backup-cli/Rakefile b/gems/gitlab-backup-cli/Rakefile
index cca7175449300ac09160bf9df759076b53696d25..bc60e76f5c61e35b678750271e6fc806b35b35c9 100644
--- a/gems/gitlab-backup-cli/Rakefile
+++ b/gems/gitlab-backup-cli/Rakefile
@@ -10,3 +10,7 @@ require "rubocop/rake_task"
 RuboCop::RakeTask.new
 
 task default: %i[spec rubocop]
+
+task :version do |_|
+  puts Gitlab::Backup::Cli::VERSION
+end
diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils.rb
index 5c8293602e45b6661bd24f4e322bc0710ad520d8..0234f17cd68e394fbd04452459a85fb72cfb3b71 100644
--- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils.rb
+++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils.rb
@@ -6,6 +6,7 @@ module Cli
       module Utils
         autoload :Compression, 'gitlab/backup/cli/utils/compression'
         autoload :PgDump, 'gitlab/backup/cli/utils/pg_dump'
+        autoload :Rake, 'gitlab/backup/cli/utils/rake'
         autoload :Tar, 'gitlab/backup/cli/utils/tar'
       end
     end
diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils/rake.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils/rake.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3c532077d1e8290236a75349504e514d1e234032
--- /dev/null
+++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils/rake.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Backup
+    module Cli
+      module Utils
+        class Rake
+          # @return [Array<String>] a list of tasks to be executed
+          attr_reader :tasks
+
+          # @return [String|Pathname] a path where rake tasks are run from
+          attr_reader :chdir
+
+          # @param [Array<String>] *tasks a list of tasks to be executed
+          # @param [String|Pathname] chdir a path where rake tasks are run from
+          def initialize(*tasks, chdir: Gitlab::Backup::Cli.root)
+            @tasks = tasks
+            @chdir = chdir
+          end
+
+          # @return [self]
+          def execute
+            Bundler.with_original_env do
+              @result = Shell::Command.new(*rake_command, chdir: chdir).capture
+            end
+
+            self
+          end
+
+          # Return whether the execution was a success or not
+          #
+          # @return [Boolean] whether the execution was a success
+          def success?
+            @result&.status&.success? || false
+          end
+
+          # Return the captured rake output
+          #
+          # @return [String] stdout content
+          def output
+            @result&.stdout || ''
+          end
+
+          # Return the captured error content
+          #
+          # @return [String] stdout content
+          def stderr
+            @result&.stderr || ''
+          end
+
+          # Return the captured execution duration
+          #
+          # @return [Float] execution duration
+          def duration
+            @result&.duration || 0.0
+          end
+
+          private
+
+          # Return a list of commands necessary to execute `rake`
+          #
+          # @return [Array<String (frozen)>] array of commands to be used by Shellout
+          def rake_command
+            %w[bundle exec rake] + tasks
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/utils/rake_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/utils/rake_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3fc3c19ce67a79f88c7a658f48d8acbffcedb37b
--- /dev/null
+++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/utils/rake_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+RSpec.describe Gitlab::Backup::Cli::Utils::Rake do
+  subject(:rake) { described_class.new('version') }
+
+  describe '#execute' do
+    it 'clears out bundler environment' do
+      expect(Bundler).to receive(:with_original_env).and_yield
+
+      rake.execute
+    end
+
+    it 'runs rake using bundle exec' do
+      expect_next_instance_of(Gitlab::Backup::Cli::Shell::Command) do |shell|
+        expect(shell.cmd_args).to start_with(%w[bundle exec rake])
+      end
+
+      rake.execute
+    end
+
+    it 'runs rake command with the defined tasks' do
+      expect_next_instance_of(Gitlab::Backup::Cli::Shell::Command) do |shell|
+        expect(shell.cmd_args).to end_with(%w[version])
+      end
+
+      rake.execute
+
+      expect(rake.success?).to eq(true)
+    end
+
+    context 'when chdir is set' do
+      let(:tmpdir) { Dir.mktmpdir }
+
+      after do
+        FileUtils.rmdir(tmpdir)
+      end
+
+      subject(:rake) { described_class.new('version', chdir: tmpdir) }
+
+      it 'runs rake in the provided chdir directory' do
+        expect_next_instance_of(Gitlab::Backup::Cli::Shell::Command) do |shell|
+          expect(shell.chdir).to eq(tmpdir)
+        end
+
+        rake.execute
+
+        expect(rake.success?).to eq(false)
+        expect(rake.stderr).to match('Could not locate Gemfile or .bundle/ directory')
+      end
+    end
+  end
+
+  describe '#success?' do
+    subject(:rake) { described_class.new('--version') } # valid command that has no side-effect
+
+    context 'with a successful rake execution' do
+      it 'returns true' do
+        rake.execute
+
+        expect(rake.success?).to be_truthy
+      end
+    end
+
+    context 'with a failed rake execution', :hide_output do
+      subject(:invalid_rake) { described_class.new('--invalid') } # valid command that has no side-effect
+
+      it 'returns false when a previous execution failed' do
+        invalid_rake.execute
+
+        expect(invalid_rake.duration).to be > 0.0
+        expect(invalid_rake.success?).to be_falsey
+      end
+    end
+
+    it 'returns false when no execution was done before' do
+      expect(rake.success?).to be_falsey
+    end
+  end
+
+  describe '#output' do
+    it 'returns the output from running a rake task' do
+      rake.execute
+
+      expect(rake.output).to match(Gitlab::Backup::Cli::VERSION)
+    end
+
+    it 'returns an empty string when the task has not been run' do
+      expect(rake.output).to eq('')
+    end
+  end
+
+  describe '#stderr' do
+    subject(:invalid_rake) { described_class.new('--invalid') } # valid command that has no side-effect
+
+    it 'returns the content from stderr when available' do
+      invalid_rake.execute
+
+      expect(invalid_rake.stderr).to match('invalid option: --invalid')
+    end
+
+    it 'returns an empty string when the task has not been run' do
+      expect(invalid_rake.stderr).to eq('')
+    end
+  end
+
+  describe '#duration' do
+    it 'returns a duration time' do
+      rake.execute
+
+      expect(rake.duration).to be > 0.0
+    end
+
+    it 'returns 0.0 when the task has not been run' do
+      expect(rake.duration).to eq(0.0)
+    end
+  end
+end