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
- Test: install dependencies from the lockfile and run the unit tests on every pull request and push.
- 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). - 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.
- 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-alpinereplaces the Node 14 image from the original post. Pick a supported LTS version of Node and revisit it each year.npm ci --omit=devinstalls exactly the lockfile versions and leaves out test-only packages, which keeps the image smaller and safer. Commit yourpackage-lock.json;npm cifails without it.- Dependencies are copied and installed before the source code, so Docker reuses the cached install layer when only your code changes.
USER noderuns the app as the unprivileged user that ships with the official Node image, not as root.- The
HEALTHCHECKlets Docker and Compose know when the app is actually ready, which the deploy step relies on. - The
.dockerignorefile keepsnode_modules,.gitand any.envfiles 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:makesimagewait fortest, anddeploywait forimage. A failing test stops everything after it. - Pull requests build but never push. The
push:andlogin-actionconditions 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_TOKENwithpackages: writeon that one job only. No long-lived registry password is stored anywhere. Per GitHub’s Container registry documentation, images are namedghcr.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-1a2b3c4points to one specific build forever. A movinglatesttag is also pushed for convenience, but the deployment uses the SHA. - Build cache (
cache-fromandcache-towithtype=gha) stores layers between runs, so builds that only change your code are fast. See Docker’s GitHub Actions guide for the options. environment: productionlets you require a manual approval and scope the deploy secrets to the production environment. See GitHub’s documentation on environments.permissions: contents: readat 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: 5IMAGE_TAGis 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.1only. 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-stoppedbrings the app back after a reboot or crash.
Then do these one-time steps:
- 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).
- Let the server pull the image: either make the package public in GitHub (appropriate for open-source images), or run
docker login ghcr.ioon the server with a token that has only theread:packagesscope. - Collect the server’s host key with
ssh-keyscanon 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. - In GitHub, create an environment called
productionand add these secrets to it:DEPLOY_SSH_KEY(the private key),DEPLOY_KNOWN_HOSTS,DEPLOY_HOSTandDEPLOY_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
versionfield 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 pruneoccasionally, 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 auditin 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.








