SKIP TO MAIN CONTENT

GitLab CI/CD pipeline with manual Vercel deployment

DATE
READ
9 min read
WORDS
1789
FORMAT
MDX

For the past years I’ve been hoping between GitHub and GitLab for hosting my code. They both have options to use CI/CD runners that will take our recent commits, run tests, and optionally deploy the results.

Initially I started building this website with NextJS, but I didn’t want it to run build and deployment steps on the Vercel platform - I wanted to have it fully controlled by me.

I assume You already know what CI/CD pipelines are, and I want to show You how I managed my jobs and the whole pipeline with manual deployment with GitLab.

Bear in mind - I’m a total noob when it comes to DevOps topics. I’m still learning these things and I highly recommend it to You. It is a lot of fun, believe me ! 🤓

How the pipeline looks

image: oven/bun:latest
stages:
- install
- quality
- build
- deploy
variables:
NODE_ENV: "production"
NEXT_TELEMETRY_DISABLED: "1"
GIT_DEPTH: 10
GIT_STRATEGY: fetch
workflow:
auto_cancel:
on_new_commit: none
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: never
- when: always
# Global cache configuration
cache:
key:
files:
- bun.lockb
paths:
- node_modules/
- .next/
policy: pull
# 1. Install dependencies
install_dependencies:
stage: install
script:
- bun install --frozen-lockfile
cache:
key:
files:
- bun.lockb
paths:
- node_modules/
- .next/
policy: pull-push
# Shared configuration for quality jobs
.quality_template: &quality_config
stage: quality
needs:
- job: install_dependencies
optional: true
lint:
<<: *quality_config
script:
- bun run lint:stylish
- bun run lint:types
type-check:
<<: *quality_config
script:
- bun run check-types
# Template for common build settings
.build_template: &build_config
stage: build
needs:
- job: install_dependencies
optional: true
cache:
key:
files:
- bun.lockb
paths:
- node_modules/
- .next/
policy: pull-push
# JOB A: Validation Build (Runs on all branches EXCEPT main)
# Purpose: Check if app builds correctly, update cache, no deploy artifacts.
build:validate:
<<: *build_config
rules:
- if: $CI_COMMIT_BRANCH != "main"
script:
- echo "Running standard build for validation (no Vercel deploy)."
- bun run build
artifacts:
paths:
- .next/
expire_in: 1 day
# JOB B: Production Build (Runs ONLY on main)
# Purpose: Create Vercel artifacts for deployment.
build:vercel_production:
<<: *build_config
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- echo "Building for Vercel Production..."
- bun install --global vercel
- verify_installation
- vercel pull --yes --environment=production --token=$VERCEL_TOKEN
- vercel build --prod --token=$VERCEL_TOKEN
artifacts:
paths:
- .vercel/output
- .next/
expire_in: 1 day
deploy:production:
stage: deploy
# Explicitly depends on the production build job
needs: ["build:vercel_production"]
rules:
- if: $CI_COMMIT_BRANCH == "main"
resource_group: production
script:
- echo "Deploying prebuilt artifacts to Vercel..."
- bun install --global vercel
- vercel deploy --prebuilt --prod --token=$VERCEL_TOKEN
-m gitlabCommitRef=$CI_COMMIT_REF_NAME
-m gitlabCommitSha=$CI_COMMIT_SHORT_SHA
-m gitlabPipeline=$CI_PIPELINE_URL
-m gitlabUser=$GITLAB_USER_LOGIN

Now since we already have the whole .gitlab-ci.yml file here, let’s break it down into smaller pieces.

Prepare container and stages

The most important thing that we have to do is to tell the container (where our code will run) which image it should use. In this example I’m using bun to run and build my codebase. You can use bun, node, ruby — whatever You actually need.

This is always kind of an entry point for our builds.

image: oven/bun:latest
stages:
- install
- quality
- build
- deploy
variables:
NODE_ENV: "production"
NEXT_TELEMETRY_DISABLED: "1"
GIT_DEPTH: 10
GIT_STRATEGY: fetch
  • image: tells which container should be pulled from Docker with an already prepared environment. This can be bun, node, ruby, etc.
  • stages: allows the pipeline to group up jobs into smaller chunks called stages for clarity
  • variables: these are the variables that will be set during our build process where GIT_DEPTH and GIT_STRATEGY are to tell the pipeline how it should fetch our codebase from the repository.

You can read more about variables on GitLab Docs(opens in new tab).

Workflow and cache configuration

workflow:
auto_cancel:
on_new_commit: none
rules:
# Don't run merge request pipelines, only branch pipelines
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: never
# Run for all other cases (branch pushes, etc.)
- when: always
# Global cache configuration
cache:
key:
files:
- bun.lockb
paths:
- node_modules/
- .next/
policy: pull
  • workflow: allows us to control how our pipelines run. We can have rules, auto_cancel and many many more GitLab workflow(opens in new tab). In this example we want to prevent the pipeline from being cancelled when we push a new commit fast, and we tell the pipeline to avoid running on the merge request event, so basically it will run when we push a new branch to our origin.
  • cache: this is an important part where we tell the pipeline what it should “save” in between jobs and save it as an artifact to be pulled by other jobs without the need to reinstall everything.

Now the cool stuff

The main “meat” of the pipeline are the jobs themselves here.

install_dependencies:
stage: install
script:
- bun install --frozen-lockfile
cache:
key:
files:
- bun.lockb
paths:
- node_modules/
- .next/
policy: pull-push
.quality_template: &quality_config
stage: quality
needs:
- job: install_dependencies
optional: true
lint:
<<: *quality_config
script:
- bun run lint:stylish
- bun run lint:types
type-check:
<<: *quality_config
script:
- bun run check-types
# Template for common build settings
.build_template: &build_config
stage: build
needs:
- job: install_dependencies
optional: true
cache:
key:
files:
- bun.lockb
paths:
- node_modules/
- .next/
policy: pull-push
# JOB A: Validation Build (Runs on all branches EXCEPT main)
# Purpose: Check if app builds correctly, update cache, no deploy artifacts.
build:validate:
<<: *build_config
rules:
- if: $CI_COMMIT_BRANCH != "main"
script:
- echo "Running standard build for validation (no Vercel deploy)."
- bun run build
artifacts:
paths:
- .next/
expire_in: 1 day
# JOB B: Production Build (Runs ONLY on main)
# Purpose: Create Vercel artifacts for deployment.
build:vercel_production:
<<: *build_config
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- echo "Building for Vercel Production..."
- bun install --global vercel
- verify_installation
- vercel pull --yes --environment=production --token=$VERCEL_TOKEN
- vercel build --prod --token=$VERCEL_TOKEN
artifacts:
paths:
- .vercel/output
- .next/
expire_in: 1 day

Let’s break down this to the smaller parts.

Installation and cache

install_dependencies:
stage: install
script:
- bun install --frozen-lockfile
cache:
key:
files:
- bun.lockb
paths:
- node_modules/
- .next/
policy: pull-push
  1. We install_dependencies just like we would do locally
    • stage - tells which stage it should be “put into” (which we prepared earlier)
    • script - we pass the command that we want to run
    • cache - we tell the runner what we want to cache and save as an artifact

Quality check

.quality_template: &quality_config
stage: quality
needs:
- job: install_dependencies
optional: true
lint:
<<: *quality_config
script:
- bun run lint:stylish
- bun run lint:types
type-check:
<<: *quality_config
script:
- bun run check-types
  1. .quality_template - we prepare a custom template for scripts that we will use within this step and save it as &quality_config.
    • stage: set in which stage it should run (defined at the beginning)
    • needs: allows us to run this stage ONLY when install_dependencies are done
  2. lint: we define the lint stage that will be part of the .quality_template.
    • scripts: defined scripts that will run and lint our code, in this case these scripts are defined in package.json
  3. type-check: type check step to run the TypeScript compiler to check if all types are okay.
    • scripts: same as above with a different command 😃

Actual build and deployment

# Template for common build settings
.build_template: &build_config
stage: build
needs:
- job: install_dependencies
optional: true
cache:
key:
files:
- bun.lockb
paths:
- node_modules/
- .next/
policy: pull-push
# JOB A: Validation Build (Runs on all branches EXCEPT main)
# Purpose: Check if app builds correctly, update cache, no deploy artifacts.
build:validate:
<<: *build_config
rules:
- if: $CI_COMMIT_BRANCH != "main"
script:
- echo "Running standard build for validation (no Vercel deploy)."
- bun run build
artifacts:
paths:
- .next/
expire_in: 1 day
# JOB B: Production Build (Runs ONLY on main)
# Purpose: Create Vercel artifacts for deployment.
build:vercel_production:
<<: *build_config
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- echo "Building for Vercel Production..."
- bun install --global vercel
- verify_installation
- vercel pull --yes --environment=production --token=$VERCEL_TOKEN
- vercel build --prod --token=$VERCEL_TOKEN
artifacts:
paths:
- .vercel/output
- .next/
expire_in: 1 day

In this case I wanted to experiment a little bit and created two job variants:

  • build:validate - will run for all branches except main to check if our pushed code works
  • build:vercel_production - will run only on the main branch, where it will build and deploy our code via Vercel CLI
  1. .build_template: &build_config - we prepare a build template so we can simply reuse it however we want.

    • this build will use the &build_config setup so we can avoid duplicated steps within our jobs
  2. build:vercel_production - we run &build_config and check if we are on the main branch:

    • if we are on the main branch, the job runs a new set of scripts to install vercel, verify_installation, vercel pull information about our projects on Vercel, and finally vercel build --prod to build our code for the production environment.

This last step is where the magic happens. Within our script we pass the --token flag with $VERCEL_TOKEN that will authenticate us on Vercel and deploy the code to our project.

GitLab Variables

GitLab allows us to set variables that will be available for all our pipelines at all times. For this we need to get to:

Our repo -> Setting -> CI/CD -> Variables

From there we will have a table where we can Add variable and in the drawer on the right we have to set:

  • Key - token key name
  • Values - token value (secret, auth_token, or any token we need)

These tokens can be found on the Vercel dashboard. In order to make these pipelines deploy our code to Vercel we need to set:

  • VERCEL_ORG_ID - found in team settings (the naming could have changed to Team ID)
  • VERCEL_PROJECT_ID - found in project settings
  • VERCEL_TOKEN - found in our user settings under Tokens

You can read more about this in Vercel Knowledge Base(opens in new tab).

Is that all ?

Yes, that’s all You need to run Your code through the pipeline to build, lint/test, and deploy Your codebase straight to Vercel.

Is this a perfect pipeline ? Of course NOT, although I really enjoyed building it from the ground up, step by step, and I suggest You try it as well. Currently with AI it is much easier to initially start and then build on top of it and make it better or optimize it to our needs.

Remember - experiment, have fun, build and break stuff while enjoying the whole process. 🤓