The GitLab CI Runner with Shell Executor lets your CI jobs run directly on the machine where the runner is installed, using the shell environment of the user the runner process is running as.

Let’s see it in action. Imagine you have a simple .gitlab-ci.yml file:

stages:
  - build

build_job:
  stage: build
  script:
    - echo "Hello from the shell executor!"
    - echo "Current directory: $(pwd)"
    - ls -la

When this job runs on a shell executor, the echo and ls commands are executed directly on the runner machine. If the runner is configured to run as the gitlab-runner user, you’ll see output like this:

Running with gitlab-runner 15.11.1 (a0987654)
  on runner-name-xyz abcdef12
Preparing the "shell" executor
Using Shell executor...
Preparing variables...
Executing "step_script" stage of the job
Using Shell executor...
Hello from the shell executor!
Current directory: /home/gitlab-runner/builds/runner-name-xyz/0/your-group/your-project
total 8
drwxr-xr-x 4 gitlab-runner gitlab-runner 4096 Jan 1 10:00 .
drwxr-xr-x 3 gitlab-runner gitlab-runner 4096 Jan 1 09:59 ..
drwxr-xr-x 2 gitlab-runner gitlab-runner 4096 Jan 1 10:00 .git
-rw-r--r-- 1 gitlab-runner gitlab-runner  123 Jan 1 09:59 .gitlab-ci.yml
...
Cleaning up project directory and file based onHOW_TO_REMOVE_FILES variable
ERROR: Job failed: exit code 1

The shell executor is attractive for its simplicity and performance: no Docker images to pull, no VM overhead. It’s essentially running commands in a temporary directory on the runner host. The runner process itself is a small binary that polls GitLab for jobs. When it receives a job, it checks out the repository code into a dedicated directory for that project and job, and then executes the script commands within that directory using the system’s default shell (usually Bash).

To configure a shell executor, you first need to install the GitLab Runner binary on your machine. Once installed, you register it with your GitLab instance using the gitlab-runner register command.

Here’s a typical registration command:

sudo gitlab-runner register \
  --url "https://gitlab.example.com/" \
  --registration-token "YOUR_REGISTRATION_TOKEN" \
  --executor "shell" \
  --description "My Shell Runner" \
  --tag-list "shell,linux" \
  --run-untagged="true" \
  --shell "bash" \
  --user "gitlab-runner"
  • --url: The URL of your GitLab instance.
  • --registration-token: The token obtained from your GitLab project or instance settings (Admin Area -> CI/CD -> Runners).
  • --executor "shell": This is the crucial part, specifying that you want to use the shell executor.
  • --description: A human-readable name for your runner.
  • --tag-list: Tags that you’ll use in your .gitlab-ci.yml to select this runner (e.g., tags: [shell]).
  • --run-untagged="true": Allows this runner to pick up jobs that don’t have any tags specified.
  • --shell "bash": Specifies the shell to use for executing commands. Common choices are bash, sh, or powershell on Windows.
  • --user "gitlab-runner": The system user that the runner process will run as, and under which the CI jobs will execute. This user needs appropriate permissions to access the build directories and execute commands.

After registration, the runner’s configuration is stored in /etc/gitlab-runner/config.toml. You’ll see an entry like this:

[[runners]]
  name = "My Shell Runner"
  url = "https://gitlab.example.com/"
  id = 1
  token = "abcdef1234567890abcdef1234567890"
  executor = "shell"
  shell = "bash"
  tag_list = ["shell", "linux"]
  run_untagged = true
  [runners.custom_build_dir]
  [runners.cache]

The gitlab-runner service needs to be running on the host. You can typically start and manage it using systemctl:

sudo systemctl start gitlab-runner
sudo systemctl enable gitlab-runner
sudo systemctl status gitlab-runner

The key to understanding the shell executor’s behavior lies in its working directory. For each job, the runner creates a directory structure like /home/gitlab-runner/builds/<runner-token>/<job-id>/<project-path>. All commands in your .gitlab-ci.yml script section are executed within this directory. This isolation is important, but it’s not as robust as containerization. If your build process requires specific dependencies, they must be installed directly on the runner machine or managed by your script.

One critical aspect often overlooked is the cleanup of build directories. By default, the runner tries to clean up after each job. However, if a job fails unexpectedly or if the HOW_TO_REMOVE_FILES variable is set to true in your .gitlab-ci.yml, old build directories might persist, consuming disk space. You can explicitly control this with GIT_CLEAN_FLAGS or by ensuring your scripts exit cleanly.

When using the shell executor, it’s vital to understand the permissions of the user the runner is running as. If the runner is configured to use root, it has full system access, which can be a security risk. It’s best practice to run the runner as a dedicated, non-privileged user like gitlab-runner. This user needs read/write access to the build directories and execute permissions for the shell and any commands used in your CI scripts.

The most surprising thing about the shell executor is that it doesn’t actually use a separate shell process for each command by default. While it executes your script commands within the context of a shell (defined by --shell), the runner itself is responsible for managing the execution environment and piping the output. It’s less about launching a new bash instance for echo "hello" and more about the runner process directly invoking the executable with its arguments and capturing stdout/stderr. This is why pwd shows the runner’s working directory, not a shell’s independent session directory.

The next logical step after mastering the shell executor is to explore how to manage dependencies and environments more effectively on the runner machine, perhaps by using package managers like apt or yum within your CI scripts, or by moving to a more isolated executor like Docker.

Want structured learning?

Take the full Gitlab-ci course →