Automation

Build and Push Docker Images with GitLab CI/CD Pipeline

GitLab’s built-in Container Registry means you can build Docker images in CI/CD pipelines and push them to a registry without any third-party service. One Git push triggers the build, tags the image with the commit SHA, pushes it to the registry, and optionally deploys it to staging. This guide sets up that entire workflow from scratch on Ubuntu 24.04 with GitLab CE 18.10, a Docker-based runner, and a real Flask application.

Original content from computingforgeeks.com - post 164629

By the end, you will have a working pipeline that tests your application, builds a multi-stage Docker image, pushes it to GitLab Container Registry with multiple tagging strategies (commit SHA, latest, semantic version), and deploys it to a staging environment automatically. Everything here was tested on a live server, and every pipeline ran successfully before writing this article. If you need GitLab installed first, see our guide on installing GitLab CE on Ubuntu 24.04.

Tested March 2026 on Ubuntu 24.04 LTS with GitLab CE 18.10.1, GitLab Runner 18.10.0, Docker 29.3.1

Prerequisites

  • Ubuntu 24.04 server with at least 4 CPU cores and 8 GB RAM (GitLab is resource-hungry)
  • Root or sudo access
  • Ports 80, 443, and 5050 open (5050 for the Container Registry)
  • Docker installed. If not, follow our guide on installing Docker on Ubuntu/Debian

Install GitLab CE

Add the GitLab repository and install the package. The EXTERNAL_URL variable tells GitLab which URL to configure for itself:

curl -sS https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.deb.sh | sudo bash

Install GitLab CE with your server’s IP or domain:

sudo EXTERNAL_URL="http://192.168.1.50" apt install -y gitlab-ce

Replace 192.168.1.50 with your server’s actual IP address or FQDN. Installation takes 3-5 minutes depending on hardware. Grab the initial root password once it finishes:

sudo cat /etc/gitlab/initial_root_password | grep 'Password:'

Save this password. It expires after 24 hours.

Enable the Container Registry

GitLab ships with a built-in Docker registry, but it is disabled by default. Open the configuration file:

sudo vi /etc/gitlab/gitlab.rb

Add these lines at the bottom (replace the IP with your server’s address):

registry_external_url "http://192.168.1.50:5050"
gitlab_rails['registry_enabled'] = true
gitlab_rails['registry_host'] = "192.168.1.50"
gitlab_rails['registry_port'] = 5050

Reconfigure GitLab to apply the changes:

sudo gitlab-ctl reconfigure

Since we are using HTTP (not HTTPS), Docker needs to be told this registry is intentionally insecure. Create or edit the Docker daemon configuration:

echo '{"insecure-registries":["192.168.1.50:5050"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker

This step is required on every machine that will push or pull images from this registry, including the GitLab Runner host. For production environments, use HTTPS with a valid TLS certificate instead.

Install and Register GitLab Runner

The GitLab Runner executes your CI/CD jobs. Install it on the same server (or a separate one for better isolation):

curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install -y gitlab-runner

Verify the installation:

gitlab-runner --version

Confirmed version 18.10.0:

Version:      18.10.0
Git revision: ac71f4d8
Git branch:   18-10-stable
GO version:   go1.25.7
Built:        2026-03-16T14:23:09Z
OS/Arch:      linux/amd64

Now register the runner with Docker executor. Get the registration token from your project’s Settings > CI/CD > Runners section, then run:

sudo gitlab-runner register \
  --non-interactive \
  --url "http://192.168.1.50" \
  --registration-token "YOUR_TOKEN" \
  --executor "docker" \
  --docker-image "docker:27" \
  --docker-privileged \
  --docker-volumes "/var/run/docker.sock:/var/run/docker.sock" \
  --description "docker-runner" \
  --tag-list "docker,build"

The key flags here: --docker-privileged allows Docker-in-Docker operations, and --docker-volumes mounts the host Docker socket so the runner can build and push images using the host’s Docker daemon. The runner confirms registration:

Registering runner... succeeded                     runner=sqVfNeDqo
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!

The runner appears in GitLab under Settings > CI/CD > Runners:

GitLab CI/CD settings showing registered Docker runner

Create the Application

Create a new project in GitLab (through the web UI or API). We will use a simple Flask application as our example. The project needs three files: the application code, a requirements file, and a multi-stage Dockerfile.

app.py

from flask import Flask, jsonify
import os

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify({
        "app": "flask-demo",
        "version": os.getenv("APP_VERSION", "dev"),
        "status": "running"
    })

@app.route("/health")
def health():
    return jsonify({"status": "healthy"}), 200

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

requirements.txt

flask==3.1.1
gunicorn==23.0.0

Dockerfile (multi-stage)

Multi-stage builds keep the final image small by separating the dependency installation from the runtime. The builder stage installs Python packages, and the production stage copies only the installed packages without the build tools:

# Stage 1: Build dependencies
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Production image
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app.py .
EXPOSE 5000
USER nobody
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"]

The production image runs as nobody (not root) and uses Gunicorn instead of Flask’s development server.

GitLab project overview showing flask-app repository with Dockerfile and pipeline

Write the CI/CD Pipeline

Create .gitlab-ci.yml in the repository root. This pipeline has three stages: test the application, build and push the Docker image, and deploy to staging.

stages:
  - test
  - build
  - deploy

variables:
  REGISTRY: $CI_REGISTRY
  IMAGE: $CI_REGISTRY_IMAGE
  DOCKER_TLS_CERTDIR: ""

test:
  stage: test
  image: python:3.12-slim
  tags:
    - docker
  script:
    - pip install -r requirements.txt
    - python -c "from app import app; print(app.name)"
  rules:
    - if: $CI_PIPELINE_SOURCE == "push"

build-latest:
  stage: build
  image: docker:27
  tags:
    - docker
  before_script:
    - echo $CI_REGISTRY_PASSWORD | docker login $REGISTRY -u $CI_REGISTRY_USER --password-stdin
  script:
    - docker build --pull -t $IMAGE:$CI_COMMIT_SHORT_SHA -t $IMAGE:latest .
    - docker push $IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $IMAGE:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

build-release:
  stage: build
  image: docker:27
  tags:
    - docker
  before_script:
    - echo $CI_REGISTRY_PASSWORD | docker login $REGISTRY -u $CI_REGISTRY_USER --password-stdin
  script:
    - docker build --pull -t $IMAGE:$CI_COMMIT_TAG -t $IMAGE:latest .
    - docker push $IMAGE:$CI_COMMIT_TAG
    - docker push $IMAGE:latest
  rules:
    - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/

deploy-staging:
  stage: deploy
  image: docker:27
  tags:
    - docker
  script:
    - echo "Pulling $IMAGE:$CI_COMMIT_SHORT_SHA"
    - docker pull $IMAGE:$CI_COMMIT_SHORT_SHA
    - docker rm -f flask-staging 2>/dev/null || true
    - docker run -d --name flask-staging -p 8888:5000 -e APP_VERSION=$CI_COMMIT_SHORT_SHA $IMAGE:$CI_COMMIT_SHORT_SHA
    - sleep 3
    - wget -qO- http://172.17.0.1:8888/health && echo "Deploy successful"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  environment:
    name: staging

Key details in this pipeline:

  • $CI_REGISTRY, $CI_REGISTRY_USER, $CI_REGISTRY_PASSWORD are predefined GitLab CI variables. You do not need to configure them manually. GitLab injects them automatically when the Container Registry is enabled
  • $CI_COMMIT_SHORT_SHA gives you an 8-character commit hash for unique image tagging
  • build-latest triggers on pushes to the main branch. It tags images with both the commit SHA and latest
  • build-release triggers only when you push a semantic version tag (v1.0.0, v2.1.3, etc.)
  • deploy-staging pulls the freshly built image and runs it as a container, then verifies the health endpoint responds
GitLab CI YAML file in repository showing multi-stage pipeline configuration

Run the Pipeline

Pushing the .gitlab-ci.yml file triggers the pipeline automatically. Navigate to Build > Pipelines to watch it run:

GitLab CI/CD pipelines list showing successful and failed pipeline runs

Click on a pipeline to see all three stages. When everything passes, you get green checkmarks across test, build, and deploy:

GitLab pipeline detail showing all three stages passed: test, build, deploy

The build job log shows Docker building the multi-stage image, tagging it with the commit SHA and latest, then pushing both tags to the Container Registry:

GitLab CI job log showing Docker image build and push to container registry

The deploy job pulls the image, starts a container, and confirms the health endpoint returns a successful response:

GitLab deploy job log showing container pull, run, and health check success

Verify the Container Registry

Navigate to Deploy > Container Registry in your project. You should see the pushed images with their tags:

GitLab Container Registry showing pushed Docker images with tags

You can also list tags from the command line:

docker login 192.168.1.50:5050
docker pull 192.168.1.50:5050/root/flask-app:latest

GitLab tracks environments too. The staging deployment appears under Operate > Environments:

GitLab environments page showing staging deployment

Tag-Based Releases (Semantic Versioning)

The build-release job triggers when you push a Git tag matching the pattern v*.*.*. Create a release tag:

git tag -a v1.0.0 -m "First production release"
git push origin v1.0.0

This triggers a separate pipeline that builds and pushes the image tagged with v1.0.0:

GitLab pipeline triggered by v1.0.0 tag for semantic version release

After both the commit-based and tag-based pipelines run, the Container Registry holds multiple tags. On our test server, the registry shows commit SHA tags from each pipeline plus the v1.0.0 release and latest:

Tag: 0e385283
Tag: 1582862b
Tag: 1aec3970
Tag: eff3633c
Tag: latest
Tag: v1.0.0

In production, you would deploy specific version tags rather than latest so you can roll back to any previous version if something breaks.

Image Tagging Strategies

Which tagging strategy you use depends on your deployment workflow:

StrategyTag ExampleWhen to Use
Commit SHA$CI_COMMIT_SHORT_SHAEvery build. Provides exact traceability back to source code
Branch name$CI_COMMIT_BRANCHFeature branches. Useful for testing specific branches
Semantic versionv1.2.3 from $CI_COMMIT_TAGReleases. Maps to Git tags for production deployments
LatestlatestAlways points to the most recent build. Convenient but risky in production

The pipeline in this guide uses commit SHA for every build (traceability) and semantic version for releases (production deployments). The latest tag is updated by both for convenience, but production systems should always reference a specific version.

Predefined CI Variables Reference

GitLab injects these variables automatically into every CI job. No manual configuration needed:

VariableValueUsed For
$CI_REGISTRY192.168.1.50:5050Docker login URL
$CI_REGISTRY_USERgitlab-ci-tokenRegistry authentication username
$CI_REGISTRY_PASSWORD(auto-generated token)Registry authentication password
$CI_REGISTRY_IMAGE192.168.1.50:5050/root/flask-appFull image path in registry
$CI_COMMIT_SHORT_SHA0e3852838-char commit hash for tagging
$CI_COMMIT_TAGv1.0.0Git tag name (only set on tag pipelines)
$CI_COMMIT_BRANCHmainBranch name (not set on tag pipelines)

Troubleshooting

Error: “Get http://registry:5050/v2/: dial tcp: lookup registry: no such host”

The runner cannot resolve the registry hostname. Make sure registry_external_url in /etc/gitlab/gitlab.rb uses an IP address or resolvable hostname, not localhost. After changing it, run sudo gitlab-ctl reconfigure.

Error: “http: server gave HTTP response to HTTPS client”

Docker defaults to HTTPS for registry communication. If your registry uses HTTP, you must add it to /etc/docker/daemon.json as an insecure registry and restart Docker. This must be done on every machine that interacts with the registry, including the GitLab Runner host.

Error: “curl: not found” in docker:27 image

The docker:27 image is Alpine-based and does not include curl. Use wget -qO- instead, or install curl with apk add --no-cache curl in the before_script.

Port conflicts in deploy jobs

When the runner mounts the host Docker socket, containers from previous pipeline runs persist on the host. Always docker rm -f the container name before starting a new one. Also avoid ports used by GitLab itself: 8080 (Puma), 9090 (Prometheus), and 9168 (GitLab Exporter).

Going Further

  • Add HTTPS to both GitLab and the Container Registry with Let’s Encrypt for production use
  • Use docker buildx for multi-architecture builds (amd64 + arm64) in the same pipeline
  • Add image scanning with Trivy or GitLab’s built-in container scanning before pushing to the registry
  • Integrate with Kubernetes deployments using GitLab’s Kubernetes agent. See our guide on deploying Kubernetes clusters with Kubespray
  • Set up cleanup policies to automatically delete old images and reclaim registry storage

Related Articles

Containers Install Docker Swarm Cluster on Debian 12/11/10 Automation Install Saltstack Master/Minion on CentOS 8 | Rocky Linux 8 Containers Install Harbor Registry on Ubuntu/Debian/Rocky/Alma Automation How To Install WildFly (JBoss) on Debian 11 / Debian 10

Leave a Comment

Press ESC to close