A CI/CD pipeline automates every step between code commit and production deployment: running tests, building Docker images, deploying to staging, and promoting to production with approval gates. Startups that implement CI/CD in the first 6 months ship 3.1x more frequently and experience 68% fewer deployment-related outages than teams deploying manually.
This guide builds a complete pipeline using GitHub Actions as the orchestrator, Docker for consistent builds, and a staging-to-production promotion flow with Slack notifications at every stage. Every configuration is production-tested and ready to copy into your repository.
---
Pipeline Architecture Overview
The pipeline has four stages: test, build, deploy-staging, and promote-to-production. Each stage runs only if the previous stage succeeds.
| Stage | Trigger | Duration | Actions |
| Test | Every push | 1-3 min | Lint, unit tests, type check |
| Build | Push to main | 2-5 min | Docker multi-stage build, push to registry |
| Deploy Staging | After build | 1-2 min | Deploy to staging, run smoke tests |
| Promote to Prod | Manual approval | 1-2 min | Swap staging image to prod, health check |
---
Step 1: Docker Multi-Stage Build
A multi-stage Dockerfile separates build dependencies from the production runtime, reducing image size by 60-80%. The build stage includes Node.js build tools while the production stage contains only the compiled output and production dependencies.
<h1>Stage 1: Build</h1>
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
<h1>Stage 2: Production</h1>
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
<h1>Create non-root user</h1>
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --production && npm cache clean --force
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
The HEALTHCHECK instruction enables Docker and orchestrators to detect unhealthy containers automatically. The non-root user prevents privilege escalation attacks. A typical Node.js production image built this way weighs 120-180 MB compared to 800+ MB for a naive single-stage build.
---
Step 2: GitHub Actions Workflow
Create `.github/workflows/deploy.yml` in your repository. This workflow handles the full lifecycle from tests through production deployment.
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
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=
type=raw,value=staging-latest
- name: Build and push
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
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy to staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: deploy
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
cd /opt/app
docker compose pull app
docker compose up -d app
sleep 5
curl -f http://localhost:3000/health || exit 1
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Staging deploy ${{ job.status }}: ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
promote-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
- name: Retag staging image for production
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging-latest
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging-latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production-latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production-latest
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /opt/app
docker compose pull app
docker compose up -d app
sleep 5
curl -f http://localhost:3000/health || exit 1
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "PRODUCTION deploy ${{ job.status }}: ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
---
Step 3: Secret Management
GitHub Actions secrets store sensitive values encrypted at rest. Never hardcode credentials in workflow files or Dockerfiles.
| Secret | Purpose | Where to Set |
| `STAGING_HOST` | Staging server IP | Repository settings |
| `STAGING_SSH_KEY` | SSH private key for staging | Repository settings |
| `PROD_HOST` | Production server IP | Repository settings |
| `PROD_SSH_KEY` | SSH private key for production | Repository settings |
| `SLACK_WEBHOOK` | Slack incoming webhook URL | Repository settings |
| `DATABASE_URL` | Database connection string | Environment secrets |
Use environment-level secrets for production credentials. Environment protection rules require manual approval before the `promote-production` job executes, giving your team a human checkpoint before every production release.
<h1>Generate a dedicated deploy SSH key</h1>
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""
<h1>Add public key to server authorized_keys</h1>
ssh-copy-id -i deploy_key.pub deploy@your-server
<h1>Add private key as GitHub secret</h1>
<h1>Settings > Secrets > Actions > New repository secret</h1>
---
Step 4: Staging Environment Configuration
The staging environment mirrors production with reduced resources. Use Docker Compose on the staging server for simplicity.
<h1>docker-compose.staging.yml</h1>
version: "3.8"
services:
app:
image: ghcr.io/your-org/your-app:staging-latest
ports:
- "3000:3000"
environment:
- NODE_ENV=staging
- DATABASE_URL=${DATABASE_URL}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_DB=app_staging
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
pgdata:
---
Step 5: Slack Notifications
Configure Slack notifications at every pipeline stage so the team knows when builds fail, staging deploys complete, and production releases land.
The `if: always()` condition on notification steps ensures Slack messages fire on both success and failure. Without it, failed jobs skip notification steps and the team remains unaware of broken deployments.
For richer notifications, use block kit format:
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Deploy Status*: Production deploy succeeded\n*Commit*: abc1234\n*Author*: @developer"
}
}
]
}
---
Pipeline Optimization Tips
- Cache Docker layers: The `cache-from: type=gha` option reuses previous build layers, cutting build time by 40-60%.
- Parallel jobs: Run lint, typecheck, and tests in parallel instead of sequentially. This reduces the test stage from 5 minutes to 2 minutes.
- Skip unchanged: Use path filters to skip pipeline runs when only documentation changes.
- Artifact promotion: Never rebuild for production. Retag the tested staging image, guaranteeing byte-identical deployment.
For teams ready to adopt GitOps practices, read our GitOps for small teams guide. If your pipeline deploys to VPS servers, follow our Node.js deployment checklist for proper server configuration. To add canary releases to your pipeline, see the canary deployment best practices.
---
Frequently Asked Questions
How much does GitHub Actions cost for a startup?
GitHub Actions includes 2,000 free minutes per month on the free plan and 3,000 minutes on the Team plan. A typical startup with 10-20 daily pushes consumes 400-800 minutes monthly, well within free tier limits. Self-hosted runners eliminate minute costs entirely for teams that need more capacity.
Should I use GitHub Container Registry or Docker Hub?
GitHub Container Registry (ghcr.io) integrates natively with GitHub Actions, uses the same GITHUB_TOKEN for authentication, and has no pull rate limits for private images. Docker Hub imposes 100 anonymous pulls per 6 hours and 200 authenticated pulls per 6 hours. For GitHub-based projects, ghcr.io is the simpler and more reliable choice.
How do I handle database migrations in the pipeline?
Run migrations as a separate step before deploying the new application version. Use a migration job that connects to the database and applies pending migrations. Never run migrations inside the application startup process because parallel container starts can cause duplicate migration attempts.
What if a staging deployment fails?
The pipeline stops at the `deploy-staging` job and the `promote-production` job never runs. Staging uses the `staging-latest` tag, so the previous working version remains available. Fix the issue, push a new commit, and the pipeline reruns from the test stage.
How do I add integration tests to the pipeline?
Add a `test-integration` job that runs after `deploy-staging`. This job hits the staging URL with end-to-end tests using Playwright or similar tools. Gate the `promote-production` job on both `deploy-staging` and `test-integration` succeeding.
Can I deploy to Kubernetes instead of VPS?
Yes. Replace the SSH deploy steps with `kubectl set image` or a GitOps approach (ArgoCD, Flux). The build and test stages remain identical. See our Docker Compose to Kubernetes migration guide for transitioning from VPS to K8s.