diff --git a/scripts/gitaly-test-spawn b/scripts/gitaly-test-spawn
index 3bf2de7676085a8eac169aa7cbda05d64e0b1c49..eed79f75224e9a96e99ce1e4e43b94952c089bbb 100755
--- a/scripts/gitaly-test-spawn
+++ b/scripts/gitaly-test-spawn
@@ -10,7 +10,10 @@ class GitalyTestSpawn
 
   def run
     install_gitaly_gems
-    spawn_gitaly
+
+    # Optionally specify the path to the gitaly config toml as first argument.
+    # Used by workhorse in test.
+    spawn_gitaly(ARGV[0])
   end
 end
 
diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb
index c5781016936e43fcc51d3283ada9795753a572a9..905c439f4d97de692812608dd08235c953177d89 100644
--- a/spec/support/helpers/gitaly_setup.rb
+++ b/spec/support/helpers/gitaly_setup.rb
@@ -123,8 +123,8 @@ def build_gitaly
     run_command(%w[make all git], env: env.merge('GIT_VERSION' => nil))
   end
 
-  def start_gitaly
-    start(:gitaly)
+  def start_gitaly(toml = nil)
+    start(:gitaly, toml)
   end
 
   def start_gitaly2
@@ -135,10 +135,11 @@ def start_praefect
     start(:praefect)
   end
 
-  def start(service)
+  def start(service, toml = nil)
+    toml ||= config_path(service)
     args = ["#{tmp_tests_gitaly_bin_dir}/#{service_binary(service)}"]
     args.push("-config") if service == :praefect
-    args.push(config_path(service))
+    args.push(toml)
 
     # Ensure user configuration does not affect Git
     # Context: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58776#note_547613780
@@ -147,7 +148,7 @@ def start(service)
     pid = spawn(env, *args, [:out, :err] => "log/#{service}-test.log")
 
     begin
-      try_connect!(service)
+      try_connect!(service, toml)
     rescue StandardError
       Process.kill('TERM', pid)
       raise
@@ -184,29 +185,37 @@ def check_gitaly_config!
     abort 'bundle check failed' unless system(env, 'bundle', 'check', out: out, chdir: gemfile_dir)
   end
 
-  def read_socket_path(service)
+  def connect_proc(toml)
     # This code needs to work in an environment where we cannot use bundler,
     # so we cannot easily use the toml-rb gem. This ad-hoc parser should be
     # good enough.
-    config_text = IO.read(config_path(service))
+    config_text = IO.read(toml)
 
     config_text.lines.each do |line|
-      match_data = line.match(/^\s*socket_path\s*=\s*"([^"]*)"$/)
+      match_data = line.match(/^\s*(socket_path|listen_addr)\s*=\s*"([^"]*)"$/)
 
-      return match_data[1] if match_data
+      next unless match_data
+
+      case match_data[1]
+      when 'socket_path'
+        return -> { UNIXSocket.new(match_data[2]) }
+      when 'listen_addr'
+        addr, port = match_data[2].split(':')
+        return -> { TCPSocket.new(addr, port.to_i) }
+      end
     end
 
-    raise "failed to find socket_path in #{config_path(service)}"
+    raise "failed to find socket_path or listen_addr in #{toml}"
   end
 
-  def try_connect!(service)
+  def try_connect!(service, toml)
     LOGGER.debug "Trying to connect to #{service}: "
     timeout = 20
     delay = 0.1
-    socket = read_socket_path(service)
+    connect = connect_proc(toml)
 
     Integer(timeout / delay).times do
-      UNIXSocket.new(socket)
+      connect.call
       LOGGER.debug " OK\n"
 
       return
@@ -217,7 +226,7 @@ def try_connect!(service)
 
     LOGGER.warn " FAILED to connect to #{service}\n"
 
-    raise "could not connect to #{socket}"
+    raise "could not connect to #{service}"
   end
 
   def gitaly_socket_path
@@ -280,20 +289,29 @@ def stop(pid)
     # The process can already be gone if the test run was INTerrupted.
   end
 
-  def spawn_gitaly
+  def spawn_gitaly(toml = nil)
     check_gitaly_config!
 
-    gitaly_pid = start_gitaly
-    gitaly2_pid = start_gitaly2
-    praefect_pid = start_praefect
+    pids = []
+
+    if toml
+      pids << start_gitaly(toml)
+    else
+      pids << start_gitaly
+      pids << start_gitaly2
+      pids << start_praefect
+    end
 
     Kernel.at_exit do
-      # In CI this function is called by scripts/gitaly-test-spawn, triggered a
-      # before_script. Gitaly needs to remain running until the container is
-      # stopped.
+      # In CI, this function is called by scripts/gitaly-test-spawn, triggered
+      # in a before_script. Gitaly needs to remain running until the container
+      # is stopped.
       next if ENV['CI']
+      # In Workhorse tests (locally or in CI), this function is called by
+      # scripts/gitaly-test-spawn during `make test`. Gitaly needs to remain
+      # running until `make test` cleans it up.
+      next if ENV['GITALY_PID_FILE']
 
-      pids = [gitaly_pid, gitaly2_pid, praefect_pid]
       pids.each { |pid| stop(pid) }
     end
   rescue StandardError
@@ -311,22 +329,22 @@ def gitaly_failure_message
 
     unless ci?
       message += "\nIf binaries are missing, try running `make -C tmp/tests/gitaly build git.`\n"
-      message += "\nOtherwise, try running `rm -rf #{gitaly_dir}`."
+      message += "\nOtherwise, try running `rm -rf #{tmp_tests_gitaly_dir}`."
     end
 
     message
   end
 
   def git_binary
-    File.join(gitaly_dir, "_build", "deps", "git", "install", "bin", "git")
+    File.join(tmp_tests_gitaly_dir, "_build", "deps", "git", "install", "bin", "git")
   end
 
   def gitaly_binary
-    File.join(gitaly_dir, "_build", "bin", "gitaly")
+    File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly")
   end
 
   def praefect_binary
-    File.join(gitaly_dir, "_build", "bin", "praefect")
+    File.join(tmp_tests_gitaly_dir, "_build", "bin", "praefect")
   end
 
   def git_binary_exists?
diff --git a/workhorse/Makefile b/workhorse/Makefile
index 890d460adbcd084846973c7a628591a3c639078c..031fe581d28aa69ab09e571389c29ea5a9a8ce74 100644
--- a/workhorse/Makefile
+++ b/workhorse/Makefile
@@ -106,7 +106,7 @@ run-gitaly: $(GITALY_PID_FILE)
 
 $(GITALY_PID_FILE): gitaly.toml
 	$(call message, "Starting gitaly")
-	cd ..; GITALY_TESTING_NO_GIT_HOOKS=1 GITALY_PID_FILE=workhorse/$(GITALY_PID_FILE) $(GITALY) workhorse/gitaly.toml &
+	cd ..; GITALY_TESTING_NO_GIT_HOOKS=1 GITALY_PID_FILE=workhorse/$(GITALY_PID_FILE) scripts/gitaly-test-spawn workhorse/gitaly.toml
 
 gitaly.toml: ../tmp/tests/gitaly/config.toml
 	sed -e 's/^socket_path.*$$/listen_addr = "0.0.0.0:8075"/;s/^\[auth\]$$//;s/^token.*$$//;s/^internal_socket_dir.*$$//' \