A practical CI/CD pipeline with GitHub Actions and Docker
A complete, copy-ready pipeline that lints, tests, builds a small Docker image, pushes it to a registry and deploys with approvals, plus the caching and security details that keep it fast and safe.
Codestria Engineering
Engineering team
A good pipeline is boring. Every pull request gets the same checks, every merge to main produces the same kind of artifact, and every deployment is the same command with a different version number. When something breaks, you know exactly which commit and which image caused it.
This article builds that pipeline with GitHub Actions and Docker for a typical Node.js service. The ideas transfer to any stack: verify early, build once, promote the same artifact through environments, and keep secrets and permissions tight.
The shape of the pipeline
- Verify on every pull request: install, lint, type-check and test. Fast feedback, no deployment.
- Build on merge to
main: produce a Docker image tagged with the commit SHA and push it to a registry. - Deploy to staging automatically using that exact image.
- Deploy to production with a manual approval, again using the same image. No rebuilds between environments.
Building once and promoting the same image is the most important principle here. If production gets a fresh build, you are deploying something that was never tested in staging, even if the source code is identical. Base images, dependency resolution and build tools can all change between builds.
It also helps to keep environment differences out of the image entirely. Configuration such as database URLs, API keys and feature flags should be injected at runtime through environment variables or mounted secrets, never baked in at build time. The image then becomes a pure function of the commit, and the only thing that changes between staging and production is the configuration around it.
A Dockerfile built for CI
A multi-stage build keeps the final image small and free of build tooling. Ordering matters for caching: copy the lockfile and install dependencies before copying source code, so a code change does not invalidate the dependency layer.
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Run as the unprivileged user that ships with the official image.
USER node
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./
EXPOSE 3000
# Exec form so Node.js receives SIGTERM directly.
CMD ["node", "dist/server.js"]Verify on every pull request
The verify job should be fast and deterministic. Use npm ci rather than npm install so the lockfile is respected exactly, cache the npm download directory with actions/setup-node, and run checks that fail the build on any problem. Run a real database as a service container for integration tests instead of mocking it.
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
- run: npm test -- --ciThe concurrency block cancels superseded runs when someone pushes again to the same branch, which saves runner minutes and shortens the queue. The top-level permissions block drops the default token to read-only; jobs that need more ask for it explicitly.
Build and push the image
On main, a second job builds the image with Docker Buildx and pushes it to GitHub Container Registry. The GitHub Actions cache backend stores Docker layers between runs, so unchanged layers are not rebuilt. Tag every image with the full commit SHA; mutable tags like latest make it impossible to know what is actually running.
build:
needs: verify
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ghcr.io/${{ github.repository }}:${{ github.sha }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxDeploy with environments and approvals
GitHub environments give you per-environment secrets, deployment history and protection rules such as required reviewers. Define staging and production in the repository settings, add a required reviewer to production, and reference them from the deploy jobs. The production job will pause until someone approves it.
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
IMAGE: ${{ needs.build.outputs.image }}
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
run: |
echo "$KUBECONFIG_DATA" | base64 -d > kubeconfig
export KUBECONFIG=$PWD/kubeconfig
kubectl set image deployment/api api="$IMAGE"
kubectl rollout status deployment/api --timeout=5m
deploy-production:
needs: [build, deploy-staging]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
IMAGE: ${{ needs.build.outputs.image }}
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
run: |
echo "$KUBECONFIG_DATA" | base64 -d > kubeconfig
export KUBECONFIG=$PWD/kubeconfig
kubectl set image deployment/api api="$IMAGE"
kubectl rollout status deployment/api --timeout=5mBecause both jobs read KUBECONFIG from their own environment, the same secret name resolves to different clusters. Passing values through env rather than interpolating them directly into the run script also avoids a class of script injection problems.
Security and hygiene
- Least-privilege tokens. Default
permissionstocontents: readand grant extra scopes per job. - Prefer OIDC over long-lived cloud keys. AWS, Google Cloud and Azure can trust GitHub's OIDC tokens, so the workflow assumes a role without storing access keys as secrets. This needs
id-token: writeon the job. - Pin third-party actions. Pin actions from outside your organisation to a full commit SHA so a moved tag cannot change what runs in your pipeline.
- Never interpolate untrusted input into scripts. Values like pull request titles or branch names should be passed through
env, not inserted intorunblocks with expressions. - Scan images. Add a vulnerability scanner step after the build and decide which severities fail the pipeline.
- Keep Dependabot or Renovate on for both npm packages and GitHub Actions versions.
Keeping it fast
Pipeline speed is a developer-experience feature. Slow pipelines encourage batching changes, which makes failures harder to diagnose. The biggest wins are usually dependency caching, Docker layer caching, cancelling superseded runs, running independent jobs in parallel and splitting slow test suites across a matrix. Measure before optimising: the Actions UI shows duration per step, and one slow step is often the whole story.
Be careful with the opposite temptation, too. Skipping tests on certain paths or making checks optional to save time tends to erode trust in the pipeline. Once engineers learn that a green build does not mean much, they stop relying on it, and the pipeline becomes ceremony. It is better to have a smaller set of checks that are fast, reliable and always required than a large suite that people routinely bypass. Flaky tests deserve the same treatment: quarantine and fix them promptly rather than adding automatic retries that hide real failures.
Rollbacks are just another deploy
Because every image is tagged with its commit SHA and kept in the registry, rolling back means deploying an older SHA through the same workflow. Add a workflow_dispatch trigger with an image input to the deploy jobs and you have a one-click, audited rollback that uses exactly the same path as a normal release. Avoid out-of-band fixes such as editing a deployment by hand in the cluster; they leave the repository and the running system disagreeing about what is deployed.
Key takeaways
- Verify every pull request with lint, type-check and tests against real service containers.
- Build one image per commit, tag it with the SHA, and promote that exact image through environments.
- Use multi-stage Dockerfiles, a non-root user and exec-form
CMD. - Use GitHub environments for per-environment secrets and production approvals.
- Minimise token permissions, prefer OIDC, pin actions and pass untrusted values through
env.
Want a pipeline like this set up for your team, or an existing one made faster and safer? Our DevOps team can help. Get in touch.
Need help with devops & cloud?
Docker, Kubernetes, CI/CD and AWS: reliable deployments, monitoring and lower cloud bills.