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) anddevelop(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 }}— Onmain, it loads.env.main; ondevelop, 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 inspectoutput - 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
latestas your image tag in production. Tag by Git SHA so every deploy is traceable and rollbacks are deterministic. - Don't run
npm installin 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 1catches 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.





