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
dockerexecutor. - Docker must be installed on the Runner host machine.
- Your CI configuration uses the
image:keyword to specify the runtime image.
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:
- Press
ato enter insert mode. - Paste the content below.
- Press
Esc. - Type
:wqand 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-clientrsync
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
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.