Skip to content
DevOps devops cicd 5 min read

Building & Pushing Docker Images in CI

Once your app is tested, the next job in a CI/CD pipeline (CI/CD = Continuous Integration / Continuous Delivery, the automated process that builds, tests, and ships your code) is usually to package it as a Docker image and store it somewhere your servers can pull it from. A Docker image is a self-contained bundle of your app plus everything it needs to run, and a registry (a server that stores and serves Docker images, like Docker Hub or GitHub Container Registry) is where those images live. This page shows you exactly how to log in to a registry with secrets, build an image tagged with the commit SHA and latest, and push it — on both GitHub Actions and GitLab CI.

Why build images in CI and not on your laptop

Building on your laptop “works on my machine” — but the machine differs from your server, you forget to push, and there is no record of which commit produced which image. Building in CI fixes all of that: every push to your repository produces a fresh, reproducible image, tagged with the exact Git commit it came from, stored in a registry where your deploy step can grab it.

When to use this: any project shipped as a container — which in 2026 is most backend apps, APIs, and many frontends. When NOT to: tiny static sites that deploy straight to a CDN or object storage; there is no container to build.

Tagging strategy: SHA plus latest

Always push two tags for the same image:

TagExampleWhat it is for
Commit SHAmyapp:a1b2c3dImmutable. Always points to exactly one build. Use this to deploy and roll back.
latestmyapp:latestA moving pointer to the newest build. Handy for local pulls and demos.

Never deploy latest to production. It is a moving target — the image behind it changes on every push, so two servers can end up running different code. Deploy the SHA tag, which never changes.

GitHub Actions: log in, build, push

GitHub gives every repository a built-in registry called GHCR (GitHub Container Registry, hosted at ghcr.io) and an automatic token, so you do not even need to create a secret for it. Create .github/workflows/docker.yml:

name: Build and push image

on:
  push:
    branches: [main]

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write          # lets the job push to GHCR
    steps:
      - uses: actions/checkout@v4

      # buildx is Docker's modern builder: enables caching and multi-arch
      - uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

A few terms in that file:

  • ${{ secrets.GITHUB_TOKEN }} — a token GitHub generates automatically for each run. No setup needed. For other registries (Docker Hub, AWS ECR) you add your own secret, shown below.
  • cache-from / cache-to with type=gha — uses GitHub’s build cache so layers that did not change are reused, turning a 5-minute build into 30 seconds.
  • github.sha — the full commit hash of the code being built.

Output (from the build step):

#12 exporting to image
#12 pushing ghcr.io/acme/myapp:8f3a1c9
#12 pushing ghcr.io/acme/myapp:latest
#12 DONE 4.1s

Build and push complete.

Pushing to Docker Hub instead

If you use Docker Hub, add two secrets in your repo (Settings → Secrets and variables → Actions): DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (an access token, not your password). Then change the login step:

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

…and use tags like docker.io/acme/myapp:${{ github.sha }}.

Multi-arch builds with buildx

Most servers run on amd64 (Intel/AMD) chips, but Apple Silicon laptops and many cloud instances (like AWS Graviton) run on arm64. A normal build only produces an image for the architecture of the runner. buildx can build both at once so a single tag works everywhere:

      - name: Build and push (multi-arch)
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

When to use multi-arch: when your developers and your servers use different CPU types, or you publish a public image. When NOT to: if every machine is amd64 — multi-arch roughly doubles build time, so skip it until you need it.

GitLab CI: the same idea

GitLab ships its own registry and injects login variables automatically. Create .gitlab-ci.yml:

build-image:
  stage: build
  image: docker:27
  services:
    - docker:27-dind            # dind = Docker-in-Docker, gives the job a Docker daemon
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" -t "$CI_REGISTRY_IMAGE:latest" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
    - docker push "$CI_REGISTRY_IMAGE:latest"

GitLab pre-fills CI_REGISTRY, CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, CI_REGISTRY_IMAGE, and CI_COMMIT_SHA for you — no manual secrets for GitLab’s own registry. Note the use of --password-stdin: it pipes the password in rather than putting it on the command line, so it never shows up in process listings or logs.

Never write a registry password directly in a docker login -p mypassword command. It gets printed in CI logs and saved in shell history. Always pipe it with --password-stdin (GitLab) or store it as an encrypted secret (GitHub).

Best practices

  • Tag every image with the immutable commit SHA, and deploy that tag — never deploy latest to production.
  • Enable layer caching (type=gha on GitHub, or a registry cache on GitLab) to cut build times dramatically.
  • Use --password-stdin or encrypted secrets for every login; never inline a password.
  • Keep your Dockerfile lean with a .dockerignore so build context stays small and fast to upload.
  • Only build multi-arch when you actually run on multiple CPU types — it doubles build time.
  • Make the build job depend on tests passing, so you never publish a broken image.
  • Scan the pushed image for vulnerabilities (e.g. with trivy) as a follow-up CI stage.
Last updated June 15, 2026
Was this helpful?