SaltyCrane Blog — Notes on JavaScript and web development

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

This describes 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 increase speed and reduce image size, it uses 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.
  • 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.
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:
  - build
  - misc
  - deploy

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

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

build:deployimage:
  extends: .base
  stage: misc
  needs: ["build:builder"]
  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"

test:cypress:
  extends: .base
  stage: misc
  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"
    - docker run "$IMAGE_CYPRESS" npm run cy:citest

test:eslint:
  extends: .base
  stage: misc
  needs: ["build:builder"]
  script:
    - docker run "$IMAGE_TEST" npm run eslint

test:typescript:
  extends: .base
  stage: misc
  needs: ["build:builder"]
  script:
    - docker run "$IMAGE_TEST" npm run tsc

deploy:
  stage: deploy
  needs: ["build:deployimage", "test:cypress", "test:eslint", "test:typescript"]
  script:
    - echo "deploy here"

.dockerignore

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

.git

References

Comments