Every time a developer on your team pushes code and manually SSHes into a server to deploy, a little piece of your reliability dies. Manual deployments are how outages happen, how bugs slip into production without going through tests, and how "it works on my machine" becomes an emergency at 2am.
This guide walks you through building a real CI/CD pipeline using GitHub Actions and Docker — the combination that powers most small-to-medium teams in 2026. By the end you'll have automated testing, automated Docker builds, and automatic deployment on every merge to main.
Here's the pipeline we'll create:
GitHub Actions uses YAML workflow files stored in .github/workflows/. Each workflow has:
push, pull_request, etc.)# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm testThis is the minimum — checkout code, set up Node, install dependencies, run tests.
Let's build a proper test workflow for a Node.js application:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run tests
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
NODE_ENV: test
run: npm test -- --coverage
- name: Upload coverage report
uses: codecov/codecov-action@v4
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}Notice the services section — GitHub Actions lets you spin up Docker containers (like a Postgres database) as part of your job. This means your tests run against a real database, not a mock.
Before building a CD pipeline, your app needs a proper Dockerfile.
# Dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./
USER appuser
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]Key decisions in this Dockerfile:
Test your Dockerfile locally first:
docker build -t myapp:test .
docker run -p 3000:3000 --env-file .env myapp:testNow the main event — the deployment pipeline:
# .github/workflows/cd.yml
name: CD
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
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 GitHub 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=sha-
type=ref,event=branch
latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker pull ghcr.io/${{ github.repository }}:latest
docker stop myapp || true
docker rm myapp || true
docker run -d \
--name myapp \
--restart unless-stopped \
-p 3000:3000 \
--env-file /opt/myapp/.env \
ghcr.io/${{ github.repository }}:latest
docker system prune -fGo to your GitHub repo → Settings → Secrets and variables → Actions. Add:
SERVER_HOST — your server's IP or domainSERVER_USER — SSH username (usually ubuntu or root)SERVER_SSH_KEY — your private SSH key (the full contents of ~/.ssh/id_rsa)For the Docker registry, GITHUB_TOKEN is automatically available — no setup needed for GitHub Container Registry.
For more complex deployments with multiple services (app + nginx + database), use Docker Compose:
# /opt/myapp/docker-compose.yml on your server
version: '3.8'
services:
app:
image: ghcr.io/yourusername/myapp:latest
restart: unless-stopped
env_file: .env
ports:
- "3000:3000"
depends_on:
- postgres
postgres:
image: postgres:16
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- app
volumes:
pgdata:Update the deploy step to use Compose:
# In your deploy script
cd /opt/myapp
docker compose pull app
docker compose up -d --no-deps app
docker system prune -fEvery good CD pipeline has a rollback path. With Docker and SHA-tagged images, rollbacks are trivial:
# On your server, rollback to a previous version
docker stop myapp
docker rm myapp
docker run -d \
--name myapp \
--restart unless-stopped \
-p 3000:3000 \
ghcr.io/yourusername/myapp:sha-abc1234Better yet, add a GitHub Actions workflow triggered manually:
# .github/workflows/rollback.yml
name: Rollback
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag to rollback to (e.g., sha-abc1234)'
required: true
jobs:
rollback:
runs-on: ubuntu-latest
steps:
- name: Rollback deployment
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
docker pull ghcr.io/${{ github.repository }}:${{ inputs.image_tag }}
docker stop myapp || true
docker rm myapp || true
docker run -d --name myapp --restart unless-stopped \
-p 3000:3000 --env-file /opt/myapp/.env \
ghcr.io/${{ github.repository }}:${{ inputs.image_tag }}1. Storing secrets in code. Use GitHub Secrets, never commit .env files.
2. Running containers as root. Always add a non-root user in your Dockerfile.
3. Not caching Docker layers. The cache-from: type=gha in the build step is critical for fast builds.
4. Not health-checking the deployment. After deploying, verify the app is actually running:
# Add to deploy script
sleep 5
if ! curl -sf http://localhost:3000/health; then
echo "Health check failed — rolling back"
exit 1
fi5. Single-environment pipelines. Add a staging environment that deploys on PR to main — catch issues before they hit production.