GitLab CI/CD takes a long time to install dependencies on every run—how can I speed it up?

When using Docker Runner with GitLab CI/CD, you often see logs like the following:

Using Docker executor with image alpine:3.20 ...

$ apk add --no-cache openssh-client rsync
fetch https://dl-cdn.alpinelinux.org/...
Installing openssh-client...
Installing rsync...

Each pipeline run re-downloads and re-installs packages—wasting time and making jobs vulnerable to network instability.

This article explains how to build a custom CI base image pre-installed with required dependencies, enabling pipelines to use a local image directly—eliminating redundant apk add commands.

I. Why Dependencies Are Reinstalled Every Time

The Docker Runner creates a fresh, temporary container for each job.

If your CI configuration looks like this:

image: alpine:3.20

before_script:
  - apk add --no-cache openssh-client rsync

The actual execution flow is:

Create a brand-new Alpine container
→ Download APK package index
→ Download openssh-client and rsync
→ Install dependencies
→ Run deployment steps
→ Destroy container

The solution is:

Pre-build a Docker image containing SSH and rsync (replace with other required tools as needed)
→ CI jobs launch this pre-built image directly
→ No more `apk add` execution

II. Prerequisites

  • GitLab Runner must be configured with the docker executor.
  • Docker must be installed on the Runner host machine.
  • Your CI configuration uses the image: keyword to specify the runtime image.

:warning: This approach does not apply if you’re using the shell executor—the image: keyword is ignored in that case.

III. Verify Runner Configuration

Log into the GitLab Runner host server:

ssh root@127.0.0.2

Check the Runner status and Docker installation:

gitlab-runner list
docker version

Inspect /etc/gitlab-runner/config.toml. A typical Docker Runner configuration looks like:

[[runners]]
  name = "test"
  executor = "docker"

  [runners.docker]
    image = "alpine:3.20"
    pull_policy = ["if-not-present"]
    volumes = ["/cache"]

The key setting is:

pull_policy = ["if-not-present"]

Which means:

Image exists locally → Use it directly  
Image missing locally → Attempt download from registry

Thus, simply pre-building the image on the Runner host enables immediate usage by CI jobs.

IV. Build the Deployment Base Image

1. Create an Image Directory

On the Runner host, run:

mkdir -p /opt/gitlab-ci-images/deploy-alpine
cd /opt/gitlab-ci-images/deploy-alpine

2. Create the Dockerfile

Run:

vim Dockerfile

Inside Vim:

  1. Press a to enter insert mode.
  2. Paste the content below.
  3. Press Esc.
  4. Type :wq and press Enter.

Dockerfile contents:

FROM alpine:3.20

RUN apk add --no-cache openssh-client rsync

This image inherits from alpine:3.20 and installs the following during build time:

  • openssh-client
  • rsync

3. Build the Image

Run:

docker build -t local/gitlab-deploy:alpine3.20-v1 .

Parameter explanation:

docker build       Builds the image  
-t                 Sets the image name and tag  
local/gitlab-deploy   Image name  
alpine3.20-v1      Image tag  
.                  Uses Dockerfile in current directory  

4. Inspect the Built Image

docker image inspect local/gitlab-deploy:alpine3.20-v1

List matching images:

docker images | grep gitlab-deploy

5. Verify Tools Are Present

docker run --rm local/gitlab-deploy:alpine3.20-v1 \
  sh -c 'ssh -V && rsync --version | head -n 1'

You should see version output for both ssh and rsync.

V. Update .gitlab-ci.yml

Your original CI configuration may look like:

deploy-master:
  stage: deploy
  image: alpine:3.20

  before_script:
    - apk add --no-cache openssh-client rsync
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh

Update it as follows:

deploy-master:
  stage: deploy
-  image: alpine:3.20
+  image: local/gitlab-deploy:alpine3.20-v1

  before_script:
-   - apk add --no-cache openssh-client rsync
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh

A complete deployment job might look like:

stages:
  - deploy

deploy-master:
  stage: deploy
  image: local/gitlab-deploy:alpine3.20-v1

  rules:
    - if: '$CI_COMMIT_BRANCH == "master"'

  resource_group: production-deploy

  variables:
    DEPLOY_HOST: "127.0.0.2"
    DEPLOY_USER: "root"
    DEPLOY_PATH: "/data/my-project"

  before_script:
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - |
      if [ -n "$GITLAB_CICD_ED25519_PRIVATE_KEY_B64" ]; then
        printf '%s' "$GITLAB_CICD_ED25519_PRIVATE_KEY_B64" |
          base64 -d > ~/.ssh/gitlab_cicd_ed25519
      else
        echo "Missing deployment private key"
        exit 1
      fi
    - chmod 600 ~/.ssh/gitlab_cicd_ed25519
    - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
    - export DEPLOY_SSH="ssh -i ~/.ssh/gitlab_cicd_ed25519 -o IdentitiesOnly=yes"

  script:
    - $DEPLOY_SSH "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$DEPLOY_PATH'"
    - |
      rsync -az --delete \
        -e "$DEPLOY_SSH" \
        --exclude='.git/' \
        --exclude='.env' \
        --exclude='node_modules/' \
        ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"

  environment:
    name: production

VI. Commit and Validate

After modifying .gitlab-ci.yml, commit and push:

git add .gitlab-ci.yml
git commit -m "Optimize CI deployment image to avoid repeated dependency installation"
git push

The new pipeline log should now show:

Using Docker executor with image local/gitlab-deploy:alpine3.20-v1
Using locally found image version due to "if-not-present"
Executing "step_script" stage
$ mkdir -p ~/.ssh

It should no longer contain:

$ apk add --no-cache openssh-client rsync
fetch https://dl-cdn.alpinelinux.org/...
Installing openssh-client...

VII. Updating the Image Later

To add further tools such as curl, bash, or git, update the Dockerfile:

FROM alpine:3.20

RUN apk add --no-cache \
    bash \
    curl \
    git \
    openssh-client \
    rsync

Build with a new version tag:

docker build -t local/gitlab-deploy:alpine3.20-v2 .

Then update .gitlab-ci.yml:

image: local/gitlab-deploy:alpine3.20-v2

:white_check_mark: We recommend using explicit semantic versions like v1, v2, etc., instead of latest. This ensures reproducibility, simplifies debugging, and allows safe rollbacks when issues arise.