Scientyfic World

How to Build a CI/CD Pipeline with Docker (GitHub Actions, GHCR and Deploy)

A CI/CD pipeline for a Dockerized app does four jobs automatically on every change: run the tests, build the image, publish it to a registry, and deploy that exact image...

Share:

Get an AI summary of this article

build CI/CD pipeline with Docker feature image

A CI/CD pipeline for a Dockerized app does four jobs automatically on every change: run the tests, build the image, publish it to a registry, and deploy that exact image to a server. This guide builds that pipeline end to end for a small Node.js API using GitHub Actions and the GitHub Container Registry, then covers the security and rollback details that tutorials usually skip.

Updated October 2026: I rewrote this 2024 post. The original built its pipeline in Jenkins and used node:14 (long out of support), version: '3' in Compose (now obsolete), a :latest tag for deployments (which makes rollbacks guesswork) and a placeholder Docker Hub account. This version uses GitHub Actions, which needs no server of your own for the pipeline, deploys immutable commit-tagged images, and every piece of code here that can run locally was run. If you use Jenkins, the same stages apply; see the note near the end.

The Pipeline at a Glance

  1. Test: install dependencies from the lockfile and run the unit tests on every pull request and push.
  2. Build and publish: build the Docker image and, on the main branch only, push it to the registry tagged with the commit (for example sha-1a2b3c4).
  3. Deploy: on the main branch, log in to the server over SSH and tell Docker Compose to run that exact tag, waiting until the container reports healthy.
  4. Roll back: if it is bad, redeploy the previous tag. Because every build has its own tag, nothing has to be rebuilt.

Prerequisites

  • A GitHub repository for your project.
  • Docker installed locally to try the image (Docker Desktop, or Docker Engine on Linux).
  • A Linux server with Docker Engine and the Compose plugin installed, reachable over SSH. A small cloud VM is enough.
  • Node.js 22 or newer for local development. The example uses Node 24.

Step 1: The Application and Its Tests

The app is a tiny Express API with a health endpoint. The code is split so the tests can start the app on a random port without running the real server:

import express from 'express';

export function createApp() {
  const app = express();

  app.get('/', (_req, res) => {
    res.json({ message: 'Hello from the pipeline', version: process.env.APP_VERSION ?? 'dev' });
  });

  app.get('/healthz', (_req, res) => res.type('text').send('ok'));

  return app;
}
import { createApp } from './app.js';

const port = Number(process.env.PORT ?? 3000);
const server = createApp().listen(port, () => console.log(`listening on ${port}`));

process.on('SIGTERM', () => server.close(() => process.exit(0)));

The tests use Node’s built-in test runner, so there is nothing extra to install:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../app.js';

async function withServer(fn) {
  const server = createApp().listen(0);
  const { port } = server.address();
  try {
    await fn(`http://127.0.0.1:${port}`);
  } finally {
    server.close();
  }
}

test('GET / returns the greeting', async () => {
  await withServer(async (base) => {
    const res = await fetch(base + '/');
    assert.equal(res.status, 200);
    assert.equal((await res.json()).message, 'Hello from the pipeline');
  });
});

test('GET /healthz returns ok', async () => {
  await withServer(async (base) => {
    const res = await fetch(base + '/healthz');
    assert.equal(await res.text(), 'ok');
  });
});
{
  "name": "demo-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "test": "node --test"
  },
  "engines": { "node": ">=22" },
  "dependencies": { "express": "^5.2.1" }
}

I ran this project: npm test passes both tests, and starting the server and calling / and /healthz with curl returns the expected responses. The server also handles SIGTERM, which is the signal Docker sends when it stops a container, so deployments shut down cleanly instead of being killed after a timeout.

Step 2: The Dockerfile

# syntax=docker/dockerfile:1
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY --chown=node:node app.js server.js ./

ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION

USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -q -O /dev/null http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "server.js"]
  • node:24-alpine replaces the Node 14 image from the original post. Pick a supported LTS version of Node and revisit it each year.
  • npm ci --omit=dev installs exactly the lockfile versions and leaves out test-only packages, which keeps the image smaller and safer. Commit your package-lock.json; npm ci fails without it.
  • Dependencies are copied and installed before the source code, so Docker reuses the cached install layer when only your code changes.
  • USER node runs the app as the unprivileged user that ships with the official Node image, not as root.
  • The HEALTHCHECK lets Docker and Compose know when the app is actually ready, which the deploy step relies on.
  • The .dockerignore file keeps node_modules, .git and any .env files out of the build context so local secrets never end up in an image layer.
docker build -t demo-api:local --build-arg APP_VERSION=local .
docker run --rm -p 3000:3000 demo-api:local
curl http://localhost:3000/

Honest note: I could not run Docker on the machine where I prepared this post. The application, tests and shutdown behaviour were run directly with Node, and the workflow and Compose files were validated (see below), but the docker build commands follow standard patterns and were not executed here.

Step 3: The GitHub Actions Pipeline

Create .github/workflows/pipeline.yml:

name: Build, test and deploy

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: pipeline-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test

  image:
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read
      packages: write
    outputs:
      tag: ${{ steps.vars.outputs.tag }}
    steps:
      - uses: actions/checkout@v7
      - name: Compute image name and tag
        id: vars
        run: |
          echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
          echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v7
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: |
            ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.tag }}
            ${{ steps.vars.outputs.image }}:latest
          build-args: APP_VERSION=${{ steps.vars.outputs.tag }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: image
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment: production
    steps:
      - name: Prepare SSH
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/id_deploy
          chmod 600 ~/.ssh/id_deploy
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
      - name: Deploy and verify
        env:
          HOST: ${{ secrets.DEPLOY_HOST }}
          USER_NAME: ${{ secrets.DEPLOY_USER }}
          TAG: ${{ needs.image.outputs.tag }}
        run: |
          ssh -i ~/.ssh/id_deploy "$USER_NAME@$HOST" "cd /opt/demo-api && IMAGE_TAG=$TAG docker compose pull && IMAGE_TAG=$TAG docker compose up -d --wait"

How it works

  • Three jobs in a chain. needs: makes image wait for test, and deploy wait for image. A failing test stops everything after it.
  • Pull requests build but never push. The push: and login-action conditions mean a pull request proves the image builds without publishing it, and fork pull requests never touch your registry.
  • Registry login uses the built-in GITHUB_TOKEN with packages: write on that one job only. No long-lived registry password is stored anywhere. Per GitHub’s Container registry documentation, images are named ghcr.io/OWNER/IMAGE, and a newly published package is private by default.
  • The image name is lowercased in the script because registry names must be lowercase, while GitHub usernames can contain capitals.
  • Tags are the short commit SHA. A tag like sha-1a2b3c4 points to one specific build forever. A moving latest tag is also pushed for convenience, but the deployment uses the SHA.
  • Build cache (cache-from and cache-to with type=gha) stores layers between runs, so builds that only change your code are fast. See Docker’s GitHub Actions guide for the options.
  • environment: production lets you require a manual approval and scope the deploy secrets to the production environment. See GitHub’s documentation on environments.
  • permissions: contents: read at the top sets the minimum for every job, and only the image job is granted more.

I linted this workflow with actionlint (no errors) and confirmed that each action’s version tag exists. I did not run it on GitHub, because that needs your repository, registry and server.

Step 4: Prepare the Server

Create a deploy directory such as /opt/demo-api and put this compose.prod.yaml in it (rename it to compose.yaml on the server so the deploy command needs no -f flag). Replace your-user with your lowercase GitHub username or organisation:

services:
  api:
    image: ghcr.io/your-user/demo-api:${IMAGE_TAG:-latest}
    ports:
      - "127.0.0.1:3000:3000"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 5
  • IMAGE_TAG is set by the pipeline for each deploy, so the server runs the exact build that just passed.
  • The port is published on 127.0.0.1 only. The app is reachable from the server itself, where a reverse proxy such as nginx or Caddy terminates HTTPS and forwards to it. Publishing without the IP would expose the port to the whole internet. Our guide to Docker port conflicts explains port publishing in detail.
  • There is no version: line, because current Compose versions treat it as obsolete.
  • restart: unless-stopped brings the app back after a reboot or crash.

Then do these one-time steps:

  1. Create a dedicated deploy user on the server that can run Docker, and an SSH key pair for it (use a key dedicated to this pipeline).
  2. Let the server pull the image: either make the package public in GitHub (appropriate for open-source images), or run docker login ghcr.io on the server with a token that has only the read:packages scope.
  3. Collect the server’s host key with ssh-keyscan on a trusted machine, and compare its fingerprint with the one the server reports, so that the pipeline can verify it is talking to the right host.
  4. In GitHub, create an environment called production and add these secrets to it: DEPLOY_SSH_KEY (the private key), DEPLOY_KNOWN_HOSTS, DEPLOY_HOST and DEPLOY_USER. Per GitHub’s guide to secrets, never put these values in the workflow file or the repository.

The deploy command runs docker compose up -d --wait. The --wait option makes Compose wait until the service is running and healthy, so the step fails (and the job turns red) when the new container does not pass its health check.

Step 5: Verify, Roll Back and Clean Up

  • Verify. After a deploy, call the public URL and check the version field in the response matches the commit that just shipped. The example app reports the tag it was built with, which is a quick way to prove which build is live.
  • Roll back. Because every build has an immutable tag, rolling back is a one-liner on the server: IMAGE_TAG=sha-previous docker compose up -d --wait. Make a note of the previous tag before you deploy, and keep old tags in the registry long enough to use them.
  • Clean up. Old images pile up on the server. Run docker image prune occasionally, and set a retention policy for old tags in the registry.
  • Database changes. Rolling back code does not roll back a database migration. Make migrations backward compatible (add before you remove) so the previous version still works against the new schema.

Security Checks to Add

  • Scan the image. Add a vulnerability scan (for example Docker Scout, see the Docker Scout documentation, or an open-source scanner) between the build and the deploy, and decide what severity should block a release. Rebuild regularly so base-image fixes reach you even when your code has not changed.
  • Scan dependencies and secrets. Run npm audit in the test job, and enable GitHub’s dependency and secret scanning on the repository.
  • Pin what matters. Pin base images to a version (or a digest), and for third-party actions that handle secrets consider pinning to a full commit SHA instead of a version tag.
  • Least privilege everywhere. A deploy key limited to one user and one directory, a registry token that can only read, and job permissions set to the minimum.
  • Protect the main branch. Require the test job to pass and a review before anything merges, since a merge to main now ships to production.

Using Jenkins Instead

Jenkins follows the same stages (test, build, push, deploy) in a Jenkinsfile, and you run and maintain the Jenkins server yourself. If you choose it, keep the same principles: build an immutable tag per commit, store registry and SSH credentials in Jenkins’ credential store rather than in the file, and review Jenkins’ own documentation on using Docker with Pipeline for the current syntax. For most small teams a hosted option like GitHub Actions removes a server you would otherwise have to patch.

Conclusion

The reliable core of a Docker pipeline is small: tests gate the build, each build gets an immutable tag, the registry login uses short-lived credentials, and the deploy waits for a health check. Start with the three jobs above, get one successful deploy and one practised rollback, and only then add scanning, approvals and environments such as staging. If your app is a React front end rather than a Node API, see how to Dockerize a React app for the image side.

Why tag Docker images with the commit SHA instead of using latest?

A SHA tag points to one specific build forever, so you always know what is running and can roll back by redeploying the previous tag. The latest tag moves with every push, which makes it hard to tell what is deployed and to recover from a bad release.

Is it safe to use GITHUB_TOKEN to push to GitHub Container Registry?

Yes, and it is preferable to a stored password. The token is created for each run and expires afterwards. Grant packages: write only to the job that publishes, and keep pull requests from forks from pushing images.

How do I roll back a Docker deployment?

Redeploy the previous image tag, for example IMAGE_TAG=sha-previous docker compose up -d –wait. This only works if you keep old tags in the registry. Database migrations are not rolled back by this, so make them backward compatible.

How do I keep secrets out of Docker images?

Never copy .env files or credentials into the image: use a .dockerignore, pass runtime configuration through environment variables or a secrets manager at deploy time, and use build secrets for anything needed during the build. Anyone who can pull an image can read every layer.

Do I need Jenkins for a Docker CI/CD pipeline?

No. Hosted services such as GitHub Actions, GitLab CI and CircleCI can do the same job without a server for you to maintain. Jenkins is a good choice if you already run it or need full control of the build environment.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts