Hi everyone 👋
I’m Kelvyn, a Frontend Engineer with 8 years of experience, mainly working with React and Next.js.
In this series, I’ll walk you through how to set up a comprehensive, production-grade GitLab CI/CD pipeline for a Next.js application, based on real-world experience running CI/CD at scale.
In Part 2, we’ll focus on building a fast and reliable build pipeline, including:
- Setting up Docker jobs with
docker-clianddocker-dind(Docker BuildKit) - Authenticating with GitLab Dependency Proxy and GitLab Container Registry
- Building images with
docker buildx, tagging, and caching strategies - Separating environments (production, staging, development)
- Running GitLab CI/CD in a cost-efficient way
If you haven’t read it yet, you may want to start with:
👉 GitLab CI/CD for Next.js — Part 0: Project & Repository Setup
👉 GitLab CI/CD for Next.js — Part 1: Validate Job Lint & Check Types
I. Set up Docker jobs with docker-cli and docker-dind
In this section, we set up a reusable Docker job template using docker-cli and docker-dind.
This template will be reused later to build and push Docker images using Docker BuildKit.
Files involved
-
templates/docker-job.ymlDefines a shared Docker job template used by build jobs.
References
templates/docker-job.yml
---
.docker-job:
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:24.0.5-cli
services:
- name: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:24.0.5-dind
alias: docker
before_script:
- echo "Authenticate with Dependency Proxy"
- echo "$CI_DEPENDENCY_PROXY_PASSWORD" | docker login \
$CI_DEPENDENCY_PROXY_SERVER \
-u $CI_DEPENDENCY_PROXY_USER \
--password-stdin
- echo "Authenticate with GitLab Container Registry"
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
Breakdown
CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX
https://docs.gitlab.com/user/packages/dependency_proxy/
Allows GitLab CI to pull base images and cached layers via GitLab Dependency Proxy, instead of directly from Docker Hub.
Benefits:
- Avoids Docker Hub rate limits
- Improves image pull speed
- Increases pipeline reliability
CI_REGISTRY
https://docs.gitlab.com/ci/docker/using_buildkit/#authenticate-with-the-gitlab-container-registry
Used to push and pull Docker images from the GitLab Container Registry.
This enables:
- Reusing images across jobs (build → test → deploy)
- Treating Docker images as deployable artifacts
- Immutable image tagging per environment for better caching
II. Set up build job with Docker buildx
In this section, we set up a build job using Docker Buildx.
Files involved
templates/build-job.yml
Defines a build job template.Dockerfile
Defines a Dockerfile with multi-layer caching, encapsulating our application.
References
templates/build-job.yml
---
include:
- local: "templates/docker-job.yml"
.build_job:
stage: build
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:tag-$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA
DOCKER_TLS_CERTDIR: "/certs"
extends:
- .docker-job
script:
- |
echo "Environment: $ENV"
echo "Image tag: $IMAGE_TAG"
echo "CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}"
- docker context create buildx-context
- docker buildx create buildx-context --name builder --bootstrap --driver \
docker-container --platform linux/amd64 --use
- docker buildx inspect --bootstrap
- |
docker buildx build \
--platform linux/amd64 \
--build-arg BUILD_ENV=$ENV \
--build-arg CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX=$CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX \
--file Dockerfile \
--tag $IMAGE_TAG \
--push .
after_script:
- docker buildx rm builder
- docker context rm buildx-context
Breakdown
Docker BuildKit
BuildKit is an improved backend that replaces the legacy Docker builder. It is the default builder for users on Docker Desktop and Docker Engine as of version 23.0.
Docker Buildx extends the Docker CLI to leverage the BuildKit engine, providing enhanced features such as better caching, multi-platform build execution, and builder instance control.
If you want to compare building with and without BuildKit, you can run the following command:
DOCKER_BUILDKIT=0 docker build -t nextjs-cicd-template-image .
IMAGE_TAG
Defines the image tag artifact for our application, allowing it to be reused in other jobs (E2E tests, deployment, etc.).
The format will look like:
registry.gitlab.com/your-group/nextjs-cicd-template:tag-v1-0-33-d34e872b
III. Set up GitLab CI job
In this section, we set up a simple build job to quickly test the pipeline.
Files involved
-
.gitlab-ci.ymlDefines the GitLab CI configuration for our repository.
include:
# ... existing config
- local: "templates/build-job.yml" # add build job template
# Build Dev
build_dev:
extends:
- .build_job
- .rules_dev
variables:
ENV: "dev"
# Build Staging
build_staging:
extends:
- .build_job
- .rules_staging
variables:
ENV: "staging"
# Build Production
build_prod:
extends:
- .build_job
- .rules_prod
variables:
ENV: "prod"
Great! Let’s commit the code and run our build job.
As a result, the job completed in ~1.47s, and the script execution time was ~1.19s. Fantastic!
In practice, we usually focus on the script execution time, because the total job duration can include runner scheduling and time spent waiting for cloud resources to be allocated.
IV. Set up Docker Image Caching
In this section, we’ll take advantage of Docker Buildx caching to speed up subsequent builds.
Files involved
-
templates/build-job.ymlDefines a build job template.
References
Let’s modify our build job template and .gitlab-ci.yml to enable cache.
.gitlab-ci.yml
Define a cache image per environment (dev, staging, production):
# Build Dev
build_dev:
# ...
variables:
ENV: "dev"
CACHE_IMAGE: $CI_REGISTRY_IMAGE:cache-dev # cache image tag for dev
# Build Staging
build_staging:
# ...
variables:
ENV: "staging"
CACHE_IMAGE: $CI_REGISTRY_IMAGE:cache-staging # cache image tag for staging
needs:
- test_staging
# Build Production
build_prod:
# ...
variables:
ENV: "prod"
CACHE_IMAGE: $CI_REGISTRY_IMAGE:cache-prod # cache image tag for production
templates/build-job.yml
---
include:
- local: "templates/docker-job.yml"
.build_job:
# ...
script:
- |
echo "Environment: $ENV"
echo "Image tag: $IMAGE_TAG"
echo "Cache image: $CACHE_IMAGE"
echo "CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}"
- docker context create buildx-context
- docker buildx create buildx-context --name builder --bootstrap --driver \
docker-container --platform linux/amd64 --use
- docker buildx inspect --bootstrap
- |
# Use BuildKit registry cache (pull + push)
docker buildx build \
--platform linux/amd64 \
--build-arg BUILD_ENV=$ENV \
--build-arg CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX=$CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX \
--file Dockerfile \
--tag $IMAGE_TAG \
--cache-from type=registry,ref=$CACHE_IMAGE \
--cache-to type=registry,ref=$CACHE_IMAGE,mode=max \
--push .
after_script:
- docker buildx rm builder
- docker context rm buildx-context
Breakdown
CACHE_IMAGE
We create three separate cache tags, one per environment (dev, staging, prod).
This avoids cache pollution and prevents cross-environment interference (e.g., different dependencies or build settings). Each environment owns its own cache image.
--cache-from and --cache-to
-
--cache-from type=registry,ref=$CACHE_IMAGEpulls cached layers from the registry. -
--cache-to type=registry,ref=$CACHE_IMAGE,mode=maxpushes the updated cache back to the registry to speed up future builds.
Let’s wrap up and commit our code to see what happens!
On the first run, the build will take longer because BuildKit needs to generate the cache and push it to the registry. You may also see a warning like:
ERROR: failed to configure registry cache importer: registry.gitlab.com/your-group/nextjs-cicd-template:cache-dev: not found
That’s expected, because the cache image doesn’t exist yet.
You can navigate to Deploy → Container Registry → nextjs-cicd-template to check the cache image size.
Now, let’s run the pipeline again!
As a result, thanks to caching, the script execution time dropped to ~1m (about 15–20% faster), and the total job time was ~1.27m. Great!
In reality, smaller images not only benefit the build job, but also deployment and E2E testing of your application. Smaller images mean faster pull times.
Summary (Part 2 Recap)
In Part 2 of this series, we built a production-grade Docker build pipeline for a Next.js application using GitLab CI/CD and Docker Buildx, with a strong focus on performance, reliability, and scalability.
We covered:
-
Reusable Docker job templates using
docker-clianddocker-dind, enabling Docker BuildKit in GitLab CI. - Secure authentication with GitLab Dependency Proxy and GitLab Container Registry to improve reliability and avoid Docker Hub rate limits.
- Docker Buildx-based build jobs, allowing better caching, multi-platform support, and controlled builder instances.
- Environment-based builds (dev, staging, production) with isolated configurations and immutable image tagging.
-
Registry-based BuildKit caching, using
--cache-fromand--cache-to, significantly reducing build times after the first run. - Practical insights into CI performance metrics, highlighting why script execution time matters more than total job duration.
By introducing environment-specific cache images, we avoided cache pollution while achieving 15–20% faster builds on subsequent runs.
Beyond CI speed, smaller and well-cached images also improve deployment speed and E2E testing performance due to faster image pulls.
In the next part, we’ll continue building on this foundation to further optimize and productionize the pipeline.
Full source code:
https://gitlab.com/kelvyn-labs/nextjs-cicd-template



Top comments (0)