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 configurationcache: key: files: - bun.lockb paths: - node_modules/ - .next/ policy: pull
# 1. Install dependenciesinstall_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 bebun,node,ruby, etc. -
stages: allows the pipeline to group up jobs into smaller chunks calledstagesfor clarity -
variables: these are the variables that will be set during our build process whereGIT_DEPTHandGIT_STRATEGYare 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 configurationcache: key: files: - bun.lockb paths: - node_modules/ - .next/ policy: pull-
workflow: allows us to control how our pipelines run. We can haverules,auto_canceland 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 themerge requestevent, 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 dayLet’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- We
install_dependenciesjust 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-
.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 wheninstall_dependenciesare done
-
-
lint: we define thelintstage 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 inpackage.json
-
-
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 dayIn this case I wanted to experiment a little bit and created two job variants:
-
build:validate- will run for all branches exceptmainto check if our pushed code works -
build:vercel_production- will run only on themainbranch, where it will build and deploy our code via Vercel CLI
-
.build_template: &build_config- we prepare a build template so we can simply reuse it however we want.- this build will use the
&build_configsetup so we can avoid duplicated steps within our jobs
- this build will use the
-
build:vercel_production- we run&build_configand check if we are on themainbranch:- if we are on the
mainbranch, the job runs a new set of scripts to installvercel,verify_installation,vercel pullinformation about our projects on Vercel, and finallyvercel build --prodto build our code for the production environment.
- if we are on the
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 toTeam ID) -
VERCEL_PROJECT_ID- found in project settings -
VERCEL_TOKEN- found in our user settings underTokens
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. 🤓