Why Bother With a CI/CD Pipeline?
You’ve just pushed a new feature. What happens next? If your process involves manually SSH-ing into a server, pulling code, running tests, and restarting services, you’re living on the edge. That manual process is slow, error-prone, and a huge time sink. A CI/CD (Continuous Integration/Continuous Deployment) pipeline automates this entire workflow. It creates a reliable, repeatable bridge from your `git push` to a live, running application.
We’re focusing on GitLab CI because it’s baked directly into GitLab. No third-party services or complex integrations are needed. If your code lives on GitLab, you have everything you need to start. The whole process is controlled by a single file in your repository: .gitlab-ci.yml. When you push new code, GitLab finds this file and uses a GitLab Runner to execute your automated workflow.
Step 1: Set Up the GitLab Runner
The GitLab Runner is the workhorse of your CI/CD process. It’s a lightweight agent that you install on a server, which then picks up and runs the jobs you define. While GitLab provides shared, auto-scaling runners, installing your own gives you complete control over the environment and keeps your entire process within your own infrastructure. This is ideal for projects that need specific dependencies or enhanced security.
Let’s install a runner on a Debian or Ubuntu server.
1. Install the GitLab Runner Package
First, add the official GitLab repository and install the runner package with a couple of commands.
# Download and add the repository script
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
# Install the latest version of the GitLab Runner
sudo apt-get install gitlab-runner -y
2. Register the Runner with Your Project
Next, you need to link the installed runner to your GitLab project. This vital step tells the runner where to listen for new jobs.
In your GitLab project, go to Settings > CI/CD and expand the Runners section. You will find the coordinator URL and a registration token needed for the next step. Keep this page open.
Now, run the registration command on your server:
sudo gitlab-runner register
You’ll be prompted for a few details:
- Enter the GitLab instance URL: This is the URL from the Runners page (e.g., https://gitlab.com/).
- Enter the registration token: Copy and paste the token from the same page.
- Enter a description for the runner: Use something descriptive, like “My Project Runner”.
- Enter tags for the runner: Tags let you assign specific jobs to this runner. For example, you could tag this runner `production` to ensure only it handles deployment jobs. You can leave this blank for now.
- Enter an executor: This is the most critical choice, as it defines the environment where your jobs execute. Your main options are `shell`, `docker`, or `kubernetes`. We strongly recommend `docker`. It creates a fresh, isolated container for every job, ensuring a clean slate and preventing dependency conflicts. If you select `docker`, you’ll be asked for a default image (e.g., `node:18` or `python:3.9`). You can start with a minimal one like `alpine:latest` and specify images per job later.
Once you complete the prompts, the runner is installed, registered, and ready for work.
Step 2: Configure Your Pipeline with .gitlab-ci.yml
With the runner waiting for instructions, it’s time to write them. You define your entire pipeline in a single file named .gitlab-ci.yml, which you place in the root of your repository.
Understanding the Structure: Stages and Jobs
Pipelines are organized into `stages`, and stages contain `jobs`. All jobs within a single stage run in parallel, while stages themselves run sequentially. A classic and effective structure is `build` -> `test` -> `deploy`.
Here’s a simple example that defines these stages and includes one placeholder job for each.
# .gitlab-ci.yml
stages:
- build
- test
- deploy
build_job:
stage: build
script:
- echo "This job builds the project."
- echo "For example, you could run 'npm install' or 'composer install' here."
run_tests:
stage: test
script:
- echo "This job runs the tests."
- echo "For example, run 'pytest' or 'npm test'."
deploy_job:
stage: deploy
script:
- echo "This job deploys the project to a server."
- echo "You might use 'scp', 'rsync', or an SSH command here."
When you commit this file, GitLab will kick off your first pipeline. It will run `build_job`, and if that succeeds, it will run `run_tests`, and finally `deploy_job`.
A Practical Example: Node.js App
Let’s create a more realistic pipeline for a Node.js application. We’ll leverage the Docker executor and define a specific Node.js image for our jobs.
# .gitlab-ci.yml
default:
image: node:18 # Use Node.js 18 for all jobs by default
stages:
- build
- test
- deploy
install_dependencies:
stage: build
script:
- echo "Installing server dependencies..."
- npm install
artifacts:
paths:
- node_modules/
expire_in: 1 hour
test_app:
stage: test
script:
- echo "Running tests..."
- npm test
needs:
- install_dependencies
deploy_to_production:
stage: deploy
script:
- echo "Deploying to production server..."
# A real deploy script would use SSH keys stored in GitLab variables
- ssh [email protected] 'cd /path/to/app && git pull && pm2 restart app_name'
environment:
name: production
when: on_success
rules:
- if: $CI_COMMIT_BRANCH == 'main'
Let’s break down the new keywords in this example:
default: image:sets a default Docker image, so you don’t have to specify `image: node:18` on every single job.artifacts:This is a crucial concept. It saves the specified `paths` (in this case, the `node_modules/` directory) after a job completes and makes them available to jobs in later stages. This means you don’t have to run `npm install` multiple times.needs:This creates a dependency, ensuring the `test_app` job only starts after `install_dependencies` has completed successfully.environment:This helps you track deployments within GitLab’s interface, creating a record of what code was deployed and when.rules:This is the modern way to control when a job runs. Here, we ensure the deployment job only ever runs on commits to the `main` branch, preventing accidental deployments from feature branches.
A critical note on deployment: never hardcode credentials like SSH keys in your .gitlab-ci.yml file. This is a massive security vulnerability. Instead, use GitLab’s built-in CI/CD variables, found in Settings > CI/CD > Variables. You can securely store a private SSH key as a ‘File’ type variable. Your script can then use this variable to authenticate without exposing the key in your repository or logs. This practice is a non-negotiable step for production-grade pipelines.
Step 3: Verify and Monitor Your Pipeline
With your .gitlab-ci.yml committed, how do you see it in action? Navigate to the CI/CD > Pipelines section in your GitLab project. This screen is your mission control for automation.
You’ll see a list of all pipeline runs, each with a status: pending, running, passed, or failed. You can click on any pipeline to see its stages and jobs. If a job fails, a red ‘X’ will show you exactly where the problem is. Clicking the failed job takes you directly to the log output, where you can find error messages to debug what went wrong. The feedback loop is immediate—one of the key advantages of a well-implemented CI/CD process.
Once you get things running smoothly, you can add a pipeline status badge to your project’s `README.md` to display your build status. You can get the embeddable code for this badge from Settings > CI/CD > General pipelines.

