CI/CD: Build & Push with GitHub Actions
Building and pushing images by hand works, but people forget steps, push from a laptop with uncommitted changes, or skip the tests on a busy day. CI/CD removes the human from those repeated steps. Continuous integration (CI) means every push is built and tested automatically. Continuous delivery or deployment (CD) means a tested version is packaged and released, and possibly deployed, automatically too.
In this lesson you will write a GitHub Actions workflow that tests your app inside a Docker build, tags the image sensibly, pushes it to GitHub Container Registry and deploys release versions to a server. If GitHub Actions is new to you, read GitHub Actions: First CI Workflow first.
The pipeline you will build
- A developer pushes a commit or opens a pull request.
- GitHub Actions builds the
teststage of the Dockerfile, which runs the tests. - If the tests pass, it builds the production image.
- For pushes to
mainand for version tags such asv2.1.0, it pushes the image to GHCR. Pull requests are built and tested but never pushed. - For version tags, a second job deploys the new version to the server.
A Dockerfile with a test stage
Running tests inside a build stage means CI needs nothing installed except Docker, and the tests run in exactly the environment the app ships in:
# syntax=docker/dockerfile:1
FROM node:26-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
FROM base AS test
RUN npm ci
COPY . .
RUN npm test
FROM base AS runtime
ENV NODE_ENV=production
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]docker build --target test . runs the tests; if npm test fails, the build fails. A normal build produces runtime, the last stage. BuildKit skips the test stage in that case, because runtime does not depend on it.
The workflow file
Save this as .github/workflows/docker.yml in your repository:
name: Docker image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Work out tags and labels
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/priya-sharma/shop-api
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run tests
uses: docker/build-push-action@v7
with:
target: test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push
uses: docker/build-push-action@v7
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxWhat each part does
onruns the workflow for pushes tomain, for tags starting withv, and for pull requests.permissionsgives the automaticGITHUB_TOKENread access to the code and write access to packages, which is what GHCR needs. Nothing else is allowed.docker/metadata-actionworks out image tags from what triggered the run, so you never type them by hand:
| Trigger | Tags produced |
|---|---|
Push to main at commit 4f2a9c1 | main, sha-4f2a9c1 |
| Pull request number 42 | pr-42 (built, not pushed) |
Git tag v2.1.0 | 2.1.0, 2.1, sha-... and latest |
It also produces standard OCI labels such as the source repository and commit, which the last step adds to the image.
docker/setup-buildx-actionprepares a BuildKit builder with features such as the GitHub Actions cache.docker/login-actionlogs in to GHCR with the built-inGITHUB_TOKEN; no personal token is needed. It is skipped for pull requests, because code from a fork must never get write access to your registry.docker/build-push-actionbuilds the image. There is no checkout step: when nocontextis given, the action builds straight from the Git commit that triggered the run. The first use builds only theteststage. The second builds the final image and pushes it unless the run is for a pull request.cache-fromandcache-to: type=ghastore BuildKit's layer cache in GitHub's cache service, so the next run reuses unchanged layers instead of starting from nothing on a fresh runner.
Running it
Commit the workflow and push. Open the Actions tab on GitHub to watch each step's log. When the run finishes, the image appears under Packages on your profile or organisation. To publish a release, tag a commit and push the tag:
git tag -a v2.1.0 -m "Release 2.1.0"
git push origin v2.1.0A few minutes later ghcr.io/priya-sharma/shop-api:2.1.0 is ready, and any server can pull it. To use Docker Hub instead, change the image name to priyasharma/shop-api, drop the registry line, and log in with your Docker Hub user name and a token stored as a repository secret, for example password: ${{ secrets.DOCKERHUB_TOKEN }}.
Adding a deploy job
Continuous deployment adds a job that runs after build, only for version tags. It connects to the server over SSH and tells Compose to pull and start the new version (the server side is set up in Deploy to a VPS with a Reverse Proxy):
deploy:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy over SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/id_ed25519
ssh deploy@shop.example.in "cd /srv/shop && ./deploy.sh ${GITHUB_REF_NAME#v}"needs: build makes the job wait for a successful build. environment: production lets you require a manual approval in the repository settings before it runs. ${GITHUB_REF_NAME#v} turns the tag v2.1.0 into 2.1.0. The SSH key belongs to a deploy user on the server that can run Docker and nothing more, and known_hosts makes SSH refuse to connect if the server's identity ever changes.
Common mistakes
- Missing
packages: write. The push fails with "denied: installation not allowed to Create organization package" or a similar permission error. - Pushing from pull requests. Pull requests from forks can contain anyone's code. Build and test them, but only push from trusted branches and tags.
- Uppercase letters in the GHCR image name. Write the owner in lowercase.
- No build cache in CI. Every run starts on a fresh machine; add
type=ghacaching or builds are slow. - Deploying
latest. Deploy the exact version tag the pipeline produced, so a rollback is just deploying the previous tag.
Key points
- CI builds and tests every change; CD packages and releases tested versions automatically.
- A Dockerfile
teststage lets CI run tests inside the same environment the app ships in. docker/metadata-actioncreates branch, PR, version and commit tags;docker/build-push-actionbuilds, caches and pushes.- Use
permissions: packages: writeand the built-inGITHUB_TOKENfor GHCR, and never push from pull requests. - A deploy job with
needs, a tag condition and an approval-protected environment can release versions to a server over SSH.
Exercise
Change the workflow so that it also runs every Monday at 03:30 UTC (to rebuild on patched base images), and so that the image name is ghcr.io/kabirmehta/fees-api. Then write the Git commands to release version 1.5.0.
Show solution
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
schedule:
- cron: "30 3 * * 1"with:
images: ghcr.io/kabirmehta/fees-apigit tag -a v1.5.0 -m "Release 1.5.0"
git push origin v1.5.0The cron fields are minute, hour, day of month, month and day of week, and 1 is Monday. Scheduled runs build the default branch, so main is rebuilt and pushed with fresh base-image layers each week. Pushing the tag v1.5.0 produces the image tags 1.5.0, 1.5 and latest.
Quick Quiz
1. Why does the login step have if: github.event_name != 'pull_request'?
Untrusted code must not receive registry write access.
2. What does docker/metadata-action produce for the Git tag v2.1.0 with the tag rules shown?
The semver rules strip the v and add major.minor; latest is added for version tags.
3. Why add cache-from and cache-to with type=gha?
Without a cache, every CI run rebuilds every layer.
4. What does needs: build do in the deploy job?
Jobs run in parallel unless needs creates an order.
Learn Docker & DevOps with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
