SaltyCrane Blog — Notes on JavaScript and web development

Next.js GitLab CI/CD Docker multi-stage example

DAG needs visualization

This is an example Next.js project with a GitLab CI/CD pipeline that does the following:

  • installs npm packages and builds static assets
  • runs ESLint, TypeScript, and Cypress
  • builds a Docker image for deployment
  • pushes the Docker image to the GitLab Container Registry

This example prepares a Docker image for deployment but doesn't actually deploy it. See an example CI/CD pipeline that deploys to Amazon ECS.

To improve speed, it uses a GitLab directed acyclic graph pipeline and Docker multi-stage builds.

Dockerfile

The Dockerfile defines 3 stages:

  • the "builder" stage installs npm packages and builds static assets. It produces artifacts (/app and /root/.cache) that are used by the cypress and deploy stages. It is also used to build an image used to run ESLint and TypeScript.
  • the "cypress" stage uses a different base image from the "builder" stage and is used to run cypress tests
  • the final deploy stage copies the /app directory from the "builder" stage and sets NODE_ENV to "production" and exposes port 3000
ARG BASE_IMAGE=node:14.16-alpine

# ================================================================
# builder stage
# ================================================================
FROM $BASE_IMAGE as builder
ENV NODE_ENV=test
ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache bash git
WORKDIR /app
COPY ./package.json ./
COPY ./package-lock.json ./
RUN CI=true npm ci
COPY . ./
RUN NODE_ENV=production npm run build

# ================================================================
# cypress stage
# ================================================================
FROM cypress/base:14.16.0 as cypress
WORKDIR /app
# copy cypress from the builder image
COPY --from=builder /root/.cache /root/.cache/
COPY --from=builder /app ./
ENV NODE_ENV=test
ENV NEXT_TELEMETRY_DISABLED=1

# ================================================================
# final deploy stage
# ================================================================
FROM $BASE_IMAGE
WORKDIR /app
COPY --from=builder /app ./
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["npm", "start"]

.gitlab-ci.yml

  • 3 images are built: test, cypress, and deploy. The test image is used for running ESLint and TypeScript and is needed for cypress and deploy. The cypress image is used for running Cypress and is needed for deploy.
  • the "deploy" stage would wait for all other stages if needs: were omitted. I only added needs: to make the directed acyclic graph visualization look better in the UI
  • it uses Docker BuildKit to make caching easier. (With BuildKit, cached layers will be automatically pulled when needed. Without BuildKit, images used for caching need to be explicitly pulled.) For comparison, see this diff adding BuildKit. Note DOCKER_BUILDKIT is set to 1 to enable BuildKit.
  • based on experiments, but not found in the Docker docs, the order of the --cache-from arguments matters. Later arguments take precedence over earlier arguments (Edit: this appeared to be true without BuildKit but not sure if it applies with BuildKit)
variables:
  # enable docker buildkit. Used with `BUILDKIT_INLINE_CACHE=1` below
  DOCKER_BUILDKIT: 1
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_TEST: $CI_REGISTRY_IMAGE/test:latest
  IMAGE_CYPRESS: $CI_REGISTRY_IMAGE/cypress:latest
  IMAGE_DEPLOY: $CI_REGISTRY_IMAGE/deploy:latest

stages:
  - build1
  - build2_and_test
  - build3_and_cypress
  - deploy

.base:
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker --version
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"

# this runs first
build:builder:
  extends: .base
  stage: build1
  script:
    - docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from "$IMAGE_TEST" --target builder -t "$IMAGE_TEST" .
    - docker push "$IMAGE_TEST"

# this runs immediately after "build:builder"
build:cypress:
  extends: .base
  stage: build2_and_test
  needs: ["build:builder"]
  script:
    - docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from "$IMAGE_CYPRESS" --cache-from "$IMAGE_TEST" --target cypress -t "$IMAGE_CYPRESS" .
    - docker push "$IMAGE_CYPRESS"

# this runs immediately after "build:cypress"
build:deployimage:
  extends: .base
  stage: build3_and_cypress
  needs: ["build:cypress"]
  script:
    - docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from "$IMAGE_DEPLOY" --cache-from "$IMAGE_TEST" --cache-from "$IMAGE_CYPRESS" -t "$IMAGE_DEPLOY" .
    - docker push "$IMAGE_DEPLOY"

# this runs immediately after "build:builder"
test:eslint:
  extends: .base
  stage: build2_and_test
  needs: ["build:builder"]
  script:
    - docker run "$IMAGE_TEST" npm run eslint

# this runs immediately after "build:builder"
test:typescript:
  extends: .base
  stage: build2_and_test
  needs: ["build:builder"]
  script:
    - docker run "$IMAGE_TEST" npm run tsc

# this runs immediately after "build:cypress"
test:cypress:
  extends: .base
  stage: build3_and_cypress
  needs: ["build:cypress"]
  script:
    - docker run "$IMAGE_CYPRESS" npm run cy:citest

# this runs after everything else finishes
deploy:
  stage: deploy
  # Note: the "deploy" stage will wait for all other stages if `needs:` is
  # omitted. I only added `needs:` to make the directed acyclic graph
  # visualization look better in the UI.
  needs: ["build:deployimage", "test:eslint", "test:typescript", "test:cypress"]
  script:
    - echo "deploy here"

.dockerignore

Adding the .git directory to .dockerignore prevented cache invalidation for the COPY . ./ command in the Dockerfile.

.git

References

Comments