All articles
DevOps#ci/cd#github actions#docker#devops

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.

FX

Codestria Engineering

Engineering team

6 min read

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

  1. Verify on every pull request: install, lint, type-check and test. Fast feedback, no deployment.
  2. Build on merge to main: produce a Docker image tagged with the commit SHA and push it to a registry.
  3. Deploy to staging automatically using that exact image.
  4. 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.

Dockerfile
# 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.

.github/workflows/ci.yml
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 -- --ci

The 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.

YAML
  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=max

Deploy 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.

YAML
  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=5m

Because 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 permissions to contents: read and 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: write on 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 into run blocks 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.

Found this useful? Share it with your team.

LinkedInX

Need help with devops & cloud?

Docker, Kubernetes, CI/CD and AWS: reliable deployments, monitoring and lower cloud bills.

Learn more