A "hello world" CI/CD pipeline is the easy part. The hard part is what comes after: handling staging versus production secrets, keeping Docker builds fast when your image has 40 layers, and automatically rolling back a broken release at 2 AM without waking anyone up. This guide closes that gap.

We'll wire up a production-grade pipeline using GitHub Actions and Docker — one you can drop into a real project today.


What We're Building

The pipeline will:

  • Trigger on pushes to main (production) and develop (staging)
  • Build a Docker image with layer caching to keep CI times under two minutes
  • Inject environment-specific secrets without leaking them into image layers
  • Push to a container registry (GitHub Container Registry, or GHCR)
  • Deploy to a remote server via SSH
  • Automatically roll back if the health check fails post-deploy

Project Structure Assumptions

your-app/
├── .github/
│   └── workflows/
│       └── deploy.yml
├── Dockerfile
├── docker-compose.prod.yml
└── scripts/
    └── healthcheck.sh

This works for any Dockerized app — Node, Python, Go, or otherwise.


Step 1: Write a Cache-Friendly Dockerfile

Most slow CI builds are a Dockerfile problem, not a GitHub Actions problem. The fix is layer ordering: put things that change least at the top.

# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]

The deps stage is only re-executed when package.json changes. Everything else rebuilds from cache. On a warm runner, this cuts build time from four minutes to under thirty seconds.


Step 2: Configure GitHub Actions With Layer Caching

Create .github/workflows/deploy.yml:

name: Build & Deploy

on:
  push:
    branches: [main, develop]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=,suffix=,format=short
            type=ref,event=branch

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            docker pull $IMAGE
            docker stop app || true
            docker run -d --name app --rm \
              -p 3000:3000 \
              --env-file /etc/app/.env.${{ github.ref_name }} \
              $IMAGE
            sleep 5
            bash /opt/scripts/healthcheck.sh || (docker stop app && exit 1)

A few things worth calling out:

  • cache-from: type=gha / cache-to: type=gha,mode=max — This uses GitHub Actions' native cache backend for BuildKit. It persists layer cache across runs without a self-hosted registry.
  • --env-file /etc/app/.env.${{ github.ref_name }} — On main, it loads .env.main; on develop, it loads .env.develop. Secrets never touch the image.
  • docker stop app && exit 1 — If the health check script returns a non-zero exit code, the new container is killed and the previous one (if you use a blue/green swap) stays live.

Step 3: Manage Environment-Specific Secrets Properly

GitHub Secrets are great for CI credentials, but runtime application secrets belong on the server — not in the workflow. The pattern above loads them via --env-file, which means:

  • They never appear in docker inspect output
  • They never get baked into an image layer
  • Rotating a secret means updating the file on the server, not re-running CI

Store your server-side env files somewhere like /etc/app/ with chmod 600 and ownership set to the deploy user only.

For the CI secrets themselves (DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY), use GitHub Environment secrets — not repository secrets. This lets you gate production deploys with required reviewers and restrict which branches can access them.


Step 4: Add a Rollback Trigger

The inline health check above handles immediate failures. For rollbacks triggered by post-deploy monitoring (e.g., error rate spikes in the first ten minutes), wire up a separate workflow:

name: Rollback

on:
  workflow_dispatch:
    inputs:
      target_sha:
        description: 'Git SHA to roll back to'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    steps:
      - name: SSH rollback
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            IMAGE=ghcr.io/${{ github.repository }}:${{ inputs.target_sha }}
            docker pull $IMAGE
            docker stop app || true
            docker run -d --name app --rm \
              -p 3000:3000 \
              --env-file /etc/app/.env.main \
              $IMAGE

Trigger this manually from the GitHub Actions UI or automate it by calling the workflow dispatch API from your alerting system (PagerDuty, Grafana, etc.).


Common Pitfalls to Avoid

  • Don't use latest as your image tag in production. Tag by Git SHA so every deploy is traceable and rollbacks are deterministic.
  • Don't run npm install in your entrypoint. Build-time dependencies belong in the Dockerfile, not the container startup script.
  • Don't skip the health check. A container that starts is not a container that works. Even a simple curl -f http://localhost:3000/health || exit 1 catches misconfigured environment variables before traffic hits users.
  • Don't mix staging and production secrets in the same GitHub Environment. Create separate environments (staging, production) with separate secret sets and protection rules.

Why This Matters for Your Project

A pipeline that only works on simple apps gives teams false confidence. When you're building a SaaS product or shipping client software under deadline pressure, a flaky deploy process is a liability — it slows down releases and creates risk exactly when stakes are highest. The setup above is designed to be the last CI/CD scaffold your team needs to configure from scratch, giving you fast builds, clean secret management, and a recovery path baked in from day one.