If you want to learn GitLab CI/CD, the fastest path is to build a real pipeline yourself. The good news is that GitLab makes the beginner experience surprisingly approachable. You do not need a separate CI server, a complicated plugin system, or a deep background in release engineering. In most cases, you only need a GitLab repository and a single file named .gitlab-ci.yml.
This tutorial walks you through getting started with GitLab CI/CD from scratch. You will learn what prerequisites you need, where the configuration file lives, how a simple pipeline is structured, what a GitLab Runner does, how jobs are matched to runners, how pipelines get triggered, how to read logs, how to debug common first-time mistakes, and how to build up from an echo-based demo to a more realistic Node.js test pipeline.
Before you create your first pipeline, make sure you have a few basics in place.
You need access to GitLab, either on GitLab.com or a self-managed GitLab instance. If you are using GitLab.com, many starter projects can use shared runners immediately.
You need a project repository where the pipeline configuration file will live. This can be:
In most personal or team projects, if you can push to the repository, you can trigger pipelines. In some organizations, runner access or protected branch rules may affect what can run.
You should know how to:
That is enough to begin.
.gitlab-ci.yml?The heart of GitLab CI/CD is a file named .gitlab-ci.yml.
This file is the pipeline definition. It tells GitLab:
.gitlab-ci.yml must be placed in the root of the repository.
That means its path should look like this:
my-project/
.gitlab-ci.yml
README.md
src/
package.jsonIf you accidentally place the file inside a subdirectory such as ci/.gitlab-ci.yml without using advanced include patterns, GitLab will not treat it as the main pipeline definition for the project.
GitLab reads .gitlab-ci.yml whenever a pipeline event occurs. Because the file lives in version control, your pipeline is managed as code. That means you can review changes, compare versions, and evolve the delivery process alongside the application.
.gitlab-ci.yml: A Minimal 3-Stage PipelineA great beginner exercise is a simple pipeline that does not depend on a real application yet. It just shows the structure of a GitLab pipeline.
Create .gitlab-ci.yml in the repository root with the following content:
stages:
- build
- test
- deploy
build-job:
stage: build
image: alpine:3.20
script:
- echo "Building the application"
- echo "Pretend a build happens here"
test-job:
stage: test
image: alpine:3.20
script:
- echo "Running tests"
- echo "Pretend automated tests run here"
deploy-job:
stage: deploy
image: alpine:3.20
script:
- echo "Deploying the application"
- echo "Pretend a deployment happens here"This is not a production pipeline, but it is a complete working example of a 3-stage GitLab pipeline.
It shows you the core building blocks:
scriptOnce this file is committed and pushed, GitLab should create a pipeline automatically.
YAML is indentation-sensitive, so learning the structure matters.
stagesThe stages array defines the order of major pipeline phases.
stages:
- build
- test
- deployIn this example, GitLab will try to run all build-stage jobs first. If that stage succeeds, it will move to the test stage. If test succeeds, it will move to deploy.
Think of stages as signposts that organize the journey.
The names build-job, test-job, and deploy-job are job identifiers.
build-job:A job name can be almost anything descriptive. Good names help you and your teammates quickly understand what each job does.
For example:
install-dependenciesrun-unit-testsdeploy-stagingstageThe stage keyword assigns a job to one of the declared stages.
stage: testIf the stage name is misspelled or does not exist in the stages list, the pipeline will fail to validate or behave unexpectedly.
scriptThe script section is the actual list of commands that the runner executes.
script:
- echo "Running tests"
- echo "Pretend automated tests run here"If any command in the script exits with a non-zero status, the job usually fails.
imageThe image keyword tells GitLab which container image to use for the job runtime.
image: alpine:3.20For a beginner demo, Alpine is a convenient lightweight Linux image. In real projects, you often choose images that already contain the tools you need, such as:
node:20python:3.12golang:1.24maven:3.9-eclipse-temurin-21A pipeline definition lives in GitLab, but GitLab itself does not magically execute your shell commands. That is the job of a GitLab Runner.
A runner is an agent that picks up CI/CD jobs and runs them in an execution environment.
When a pipeline starts, GitLab looks for an available runner that is allowed to execute the job. The runner then:
Without a runner, the pipeline definition exists, but nothing can actually run.
When learning GitLab Runner basics, you will encounter two common models.
Shared runners are provided for multiple projects, usually managed centrally by GitLab.com or by your organization. They are the easiest option for beginners because you often do not need to install anything yourself.
Benefits of shared runners:
Possible limits:
Self-hosted runners are runners you install and manage yourself. They are useful when you need:
They require more maintenance, but they offer more flexibility.
A beginner question often sounds like this: How does GitLab decide where a job runs?
GitLab uses runner availability, scope, and optionally tags.
If your project is allowed to use shared runners and a job has no special tag requirement, GitLab can send that job to the shared runner pool.
Runners can be registered with tags such as:
dockernodelinuxproductionA job can request matching tags:
test-node:
stage: test
image: node:20
tags:
- docker
- node
script:
- npm ci
- npm testGitLab will look for a runner that has all required tags. If none are available, the job will stay pending.
Tags help make sure the right job lands on the right infrastructure. For example, a deployment job that needs VPN access or cloud credentials should not run on an arbitrary general-purpose runner.
There are several ways to start a pipeline in GitLab.
The most common trigger is a Git push. If .gitlab-ci.yml exists and the branch is configured normally, a pipeline is created after the push.
Pipelines can also run when a merge request is opened or updated. These are especially valuable because they show whether a proposed change passes validation before it is merged.
You can trigger a pipeline manually from the GitLab UI. This is useful for reruns, ad hoc checks, or deployment jobs that should only run on demand.
GitLab can run pipelines on a schedule, such as every night or every Monday morning. Scheduled pipelines are useful for routine maintenance tasks, dependency scans, or regular integration checks.
Different triggers support different workflows:
Once your .gitlab-ci.yml file exists, commit and push it.
A typical local flow looks like this:
git add .gitlab-ci.yml
git commit -m "Add first GitLab CI pipeline"
git push origin mainAfter the push, open the project in GitLab and navigate to the pipeline view.
GitLab provides a visual pipeline interface that is beginner-friendly once you know what to look for.
In the Build > Pipelines area, you can usually see:
This is the top-level view of what happened.
Click into a pipeline to see the stage-by-stage layout. This view helps you understand which jobs ran in which order and where a failure happened.
Click a job name to open its live or completed log output. This is where you see the commands that ran and the exact output they produced.
The status colors communicate quickly:
Reading these statuses becomes second nature once you use pipelines regularly.
Job logs are one of the most important beginner skills in GitLab CI/CD.
A job log often shows:
script sectionFor example, if your script says:
script:
- npm ci
- npm testthen the job log will show the output from both commands.
When a job fails, ask these questions:
The first meaningful error message is often more useful than the final one. Beginners sometimes scroll straight to the bottom, but the root cause is often earlier in the output.
Almost everyone hits a few small issues while creating a first pipeline.
YAML uses spaces to define structure. A misplaced indent can break the file.
For example, this is wrong:
build-job:
stage: build
script:
- echo "Oops"The keys must be indented correctly under the job name.
If a job says stage: tests but the stages list only contains test, GitLab cannot place the job correctly.
.gitlab-ci.ymlCreating the file locally is not enough. GitLab only sees it after you commit and push it.
If your job runs npm test but the image is alpine:3.20, npm may not be available unless you install it manually. Pick an image that already contains the tools you need.
If no suitable runner is available, the job may remain pending forever. Check whether shared runners are enabled or whether your project requires a specific tagged runner.
Remember that the main pipeline file belongs in the root of the repository.
Once the echo-based demo makes sense, the next step is to run something real.
Imagine a Node.js project with a package.json and a test script. A basic test pipeline might look like this:
stages:
- build
- test
- deploy
build-app:
stage: build
image: node:20
script:
- npm ci
- npm run build
test-app:
stage: test
image: node:20
script:
- npm ci
- npm test
deploy-preview:
stage: deploy
image: alpine:3.20
script:
- echo "Deploy step placeholder"This example introduces a practical improvement: the build and test jobs now run in node:20, which already includes Node.js and npm.
npm ci is often preferredIn CI, npm ci is usually better than npm install because it uses the lock file predictably and is designed for automated environments.
When you are learning, it is smart to separate pipeline structure from deployment complexity. First get the pipeline green. Then add real deployment logic later.
.gitlab-ci.yml Example with AnnotationsHere is a beginner-friendly working example that combines the key ideas from this tutorial:
stages:
- build
- test
- deploy
build-app:
stage: build
image: node:20
script:
- echo "Installing dependencies"
- npm ci
- echo "Building application"
- npm run build
run-tests:
stage: test
image: node:20
script:
- echo "Installing dependencies for tests"
- npm ci
- echo "Running test suite"
- npm test
deploy-demo:
stage: deploy
image: alpine:3.20
script:
- echo "This is where a real deployment command would run"stages declares the order: build, then test, then deploy.build-app compiles the project.run-tests validates it.deploy-demo stands in for a later real deployment.This is still small, but it looks and feels like a real pipeline rather than a toy example.
One nice beginner feature in GitLab is the pipeline status badge.
A badge is a small status image you can place in your README.md to show whether the default branch pipeline is passing.
Why it is useful:
The exact badge URL comes from the GitLab UI, but conceptually it turns the pipeline result into a visible quality indicator right on the project homepage.
Sooner or later, your first ârealâ pipeline will fail. That is normal.
A calm debugging process helps a lot.
Look at the pipeline graph and identify which specific job failed.
Read the log carefully from top to bottom. Search for the first genuine error.
Does the image contain the tool you are calling? For example, npm test needs Node and npm.
If the same command fails locally, the issue may be in the code or scripts rather than GitLab.
Do not change ten things at once. Small, deliberate edits are easier to reason about.
Each new push creates another feedback cycle. That loop is the learning engine of CI/CD.
When you are just starting, remember these principles:
.gitlab-ci.yml like application codeThe goal is not to build the perfect enterprise pipeline on day one. The goal is to understand the mechanics and create a reliable foundation.
To create your first GitLab CI/CD pipeline, you place a .gitlab-ci.yml file in the repository root, define stages and jobs, choose appropriate images, commit the file, push it, and watch GitLab trigger the pipeline. A runner executes the work, the UI shows the status, and job logs tell you exactly what happened.
Once you are comfortable with this flow, you are ready to learn more advanced topics such as artifacts, caching, variables, rules, environments, and deployment strategies. But the core idea stays simple:
push code, let the pipeline run, read the feedback, improve the system.
<MCQExercise exercise={{ id: "gitlab-started-q1", title: "Question 1: Pipeline File Location", description: "Where should the main .gitlab-ci.yml file be placed for a standard GitLab pipeline?", options: [ { id: "a", text: "Inside the src directory so the build can find it" }, { id: "b", text: "In the root of the repository" }, { id: "c", text: "Only in the GitLab web UI, never in the repository" }, { id: "d", text: "Inside a hidden .ci folder unless the runner is self-hosted" }, ], correctId: "b", explanation: "GitLab looks for the main .gitlab-ci.yml file in the repository root unless you set up more advanced include patterns.", wrongExplanations: { a: "The pipeline file is not normally stored inside application source folders such as src.", c: "GitLab CI/CD is designed around pipeline configuration as code stored in the repository.", d: "A hidden .ci folder is not the standard location for the main GitLab pipeline definition.", }, }} />
<MCQExercise exercise={{ id: "gitlab-started-q2", title: "Question 2: GitLab Runners", description: "What is the role of a GitLab Runner?", options: [ { id: "a", text: "It stores Git history for the repository" }, { id: "b", text: "It reviews merge requests and approves deployments" }, { id: "c", text: "It picks up CI/CD jobs, runs them, and sends the results back to GitLab" }, { id: "d", text: "It converts YAML into Markdown for the GitLab UI" }, ], correctId: "c", explanation: "A GitLab Runner is the execution agent that actually performs the job steps defined in the pipeline.", wrongExplanations: { a: "Git history is handled by Git and the repository hosting platform, not by the runner execution agent.", b: "Approvals are a workflow or governance concern; runners execute jobs.", d: "Runners do not transform YAML into Markdown. They execute job commands.", }, }} />
<MCQExercise exercise={{ id: "gitlab-started-q3", title: "Question 3: First Debugging Step", description: "When a GitLab CI job fails, what is the best first place to investigate?", options: [ { id: "a", text: "The job log output in the GitLab UI" }, { id: "b", text: "The README badge color alone" }, { id: "c", text: "Only the commit message that triggered the pipeline" }, { id: "d", text: "The list of repository branches, even if the job never ran" }, ], correctId: "a", explanation: "The job log shows the commands that ran, their output, and the error messages that explain why the job failed.", wrongExplanations: { b: "A badge can show overall status, but it does not explain the cause of a failure.", c: "The commit message rarely contains the runtime details needed to debug the failure.", d: "Branch listings do not provide the execution details found in the job logs.", }, }} />
No runner has picked the job up. The three common causes are that shared runners are disabled for the project, that your job specifies a tags: value no available runner has, and that you are on a self-managed instance with no registered runner at all. Open the job and GitLab usually states which of these it is. Tag mismatches are the most frequent. A job tagged docker only runs on a runner registered with that same tag, and a typo leaves it waiting indefinitely.
Shared runners are provided by GitLab, require no setup, and are billed by the minute on GitLab.com beyond the free allowance. Self-hosted runners are machines you register and operate yourself, which gives you control over the hardware, lets jobs reach private networks, and removes per-minute costs at the price of maintaining them. Most teams start on shared runners and move specific jobs to self-hosted ones when they need more resources, faster builds through warm caches, or access to internal systems.
The job's Docker image does not contain the tool you are calling. Each job runs in a fresh container from the image: you specify, with nothing inherited from other jobs or from your laptop. If a job runs npm ci it needs a Node image; if it runs aws s3 cp it needs the AWS CLI installed. Either choose an image that already includes the tool, or install it in a before_script. Installing tools on every run is slower, so a purpose-built image is usually worth it once a pipeline stabilises.
No. Jobs run inside a container chosen with the image: keyword, and that image can be any public one, node:22, python:3.12, golang:1.23. You only need your own Dockerfile if you are building an image as part of the pipeline, or if you want a custom job environment with your specific toolchain preinstalled. Beginning with an off-the-shelf image is the normal starting point.
Open the job in the GitLab UI and read the log from the bottom up. The failing command and its output are almost always in the last twenty lines, above the exit code. Each job log shows the full script as it ran, so you can see exactly which command produced the error. For problems you cannot reproduce from the log, adding set -x at the start of the script prints each command before running it, which makes variable expansion and path problems visible immediately.
See how a GitLab pipeline is built: how pipelines, stages and jobs relate, what runners do, how needs breaks stage lockstep and where artifacts fit.
Define GitLab CI stages and jobs: the stages keyword and ordering, job scripts, core job keywords, rules and only/except conditions, and parallel jobs.
Use GitLab CI artifacts to pass files between jobs and keep build output: set paths and expire_in, download from the UI, and compare dependencies with needs.