GitLab CI/CD can execute jobs in parallel within a stage, not just sequentially.

Let’s watch this pipeline in action. Imagine a simple .gitlab-ci.yml file for a web application:

stages:
  - build
  - test
  - deploy

build_app:
  stage: build
  script:
    - echo "Building the Docker image..."
    - docker build -t myapp:$CI_COMMIT_SHA .
    - echo "Image built!"

run_tests:
  stage: test
  script:
    - echo "Running unit tests..."
    - docker run myapp:$CI_COMMIT_SHA npm test
    - echo "Unit tests passed."

lint_code:
  stage: test
  script:
    - echo "Linting code..."
    - docker run myapp:$CI_COMMIT_SHA npm run lint
    - echo "Linting complete."

deploy_staging:
  stage: deploy
  script:
    - echo "Deploying to staging..."
    - ./deploy.sh staging myapp:$CI_COMMIT_SHA
    - echo "Deployment to staging finished."

When this pipeline runs, GitLab doesn’t wait for build_app to finish and then run run_tests, and then lint_code sequentially. Instead, once build_app (in the build stage) completes successfully, GitLab immediately looks at the test stage. It sees two jobs, run_tests and lint_code, and if there are available runners, it will spin up two separate execution environments and run both of these jobs concurrently. The deploy_staging job in the deploy stage will only start after both run_tests and lint_code have successfully completed.

This parallelism is key to speeding up your CI/CD process. By defining multiple jobs within the same stage, you tell GitLab, "These tasks can happen at the same time, so go ahead and do it." This is incredibly useful for tasks that are independent of each other, like running different types of tests (unit, integration, security scans) or building different components of your application.

The mental model here is built around stages and jobs.

  • Stages: These are the sequential phases of your pipeline. A pipeline progresses from one stage to the next only after all jobs in the preceding stage have completed successfully. The order of stages is defined by their appearance in the stages keyword at the top of your .gitlab-ci.yml.
  • Jobs: These are the individual tasks that get executed. Each job has a script section defining the commands to run. Jobs within the same stage can run in parallel if resources are available. Jobs in different stages run sequentially.

You control the flow with:

  • stages: Defines the order of execution for the pipeline’s phases. stages: [build, test, deploy] means build happens first, then test, then deploy.
  • script: The actual commands to be executed within a job.
  • stage: Assigns a job to a specific stage. stage: test places the job in the test phase.
  • image: Specifies the Docker image to use for the job’s environment.
  • tags: Used to select specific runners that match the defined tags.

The core concept of stages is that they are executed in order. If you have stages: [a, b, c], stage b will never start until all jobs in stage a have finished successfully. However, within stage b, if you have job_b1 and job_b2, they will run in parallel. This is the fundamental way to achieve speedups: parallelize within stages, then sequence the stages.

When you define multiple jobs under the same stage keyword (like run_tests and lint_code both assigned to stage: test), GitLab doesn’t inherently know which job should run first if they were dependent. By default, it assumes they are independent and can run in parallel. If you did need a job in stage test to run after another job in stage test, you’d typically handle that by placing them in different stages or using needs (a more advanced feature for defining job dependencies within or across stages, bypassing the strict stage-by-stage execution).

The most surprising thing about how GitLab CI handles job execution within a stage is that it’s not just about runner availability; it’s also about the implicit assumption of independence. If you have two jobs in the same stage, GitLab will try to run them concurrently by default, even if one logically could have run before the other. This is a powerful optimization but can be a trap if you haven’t explicitly considered dependencies.

One common pattern is to have a "build" stage that produces an artifact (like a Docker image or a compiled binary), and then subsequent "test" and "deploy" stages that consume those artifacts. The build_app job creates a Docker image. This image is then used by run_tests and lint_code when they execute. GitLab automatically handles passing artifacts between stages if configured, but here, the dependency is managed by the job scripts themselves referencing the same image tag (myapp:$CI_COMMIT_SHA).

You’ll often encounter situations where a job in a later stage fails because an artifact wasn’t correctly passed or generated in an earlier stage. This usually points to an issue with artifact configuration or a failure in the job that was supposed to produce the artifact.

The next concept you’ll likely explore is how to manage dependencies between jobs more granularly using the needs keyword, allowing for more complex DAG (Directed Acyclic Graph) pipelines instead of just linear stages.

Want structured learning?

Take the full Gitlab course →