Automating Your Deploy Process with GitHub Actions CI/CD
If you've ever deployed code by manually SSH-ing into a server, running a build, praying nothing breaks, and copying files over — you already know why CI/CD exists. GitHub Actions makes it possible to turn that entire ritual into something that happens automatically, consistently, and safely every time you push code.
In this post, we'll walk through what CI/CD actually means in practice, and build a real GitHub Actions workflow that tests, builds, and deploys an application automatically.
Why Automate Deployment?
Manual deployments are slow, error-prone, and don't scale with a team. A good CI/CD pipeline gives you:
- Consistency — the same steps run every time, in the same environment, removing "it worked on my machine" problems.
- Speed — merging a PR can trigger a deploy within minutes, instead of waiting on someone to do it by hand.
- Safety nets — automated tests and checks run before anything reaches production, catching regressions early.
- Auditability — every deployment is tied to a commit, a workflow run, and a log you can go back and inspect.
GitHub Actions is a natural fit here because it lives right next to your code. There's no separate CI server to maintain — your pipeline is just a YAML file in your repository.
The Anatomy of a GitHub Actions Workflow
Every GitHub Actions pipeline lives under .github/workflows/ as a .yml file. A workflow is built from a few core concepts:
-
Triggers (
on) — what causes the workflow to run (a push, a pull request, a schedule, a manual trigger). - Jobs — groups of steps that run on a virtual machine (called a "runner").
- Steps — individual commands or reusable actions within a job.
- Secrets — encrypted values (like API keys or SSH credentials) injected into the workflow at runtime.
A typical CI/CD pipeline has two conceptual halves: CI (build and test the code) and CD (deploy it somewhere). You can combine both into a single workflow, or split them — CI runs on every PR, CD runs only when code lands on main.
Building a CI/CD Pipeline Step by Step
Here's the shape of the pipeline we're about to build — two workflows in the same repo, one gating changes and one shipping them:
Let's build a pipeline for a Node.js app that runs tests on every pull request, then deploys to production whenever code merges into main.
Step 1: Continuous Integration
First, we want every pull request to be automatically tested before it can be merged.
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up 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 tests
run: npm test
This workflow triggers on every pull request targeting main. It checks out the code, installs dependencies with a cached npm ci for speed, then runs linting and tests. If any step fails, the PR gets a red X and merging can be blocked until it's fixed — a simple but powerful quality gate.
Step 2: Continuous Deployment
Once code is merged into main, we want to build it and ship it automatically. Here's an example that builds a Docker image and deploys it to a server over SSH:
name: CD
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install and build
run: |
npm ci
npm run build
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myorg/myapp:latest
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
docker pull myorg/myapp:latest
docker compose up -d
A few things worth calling out:
-
Secrets (
secrets.DOCKERHUB_TOKEN,secrets.SERVER_SSH_KEY, etc.) are stored in your repository's Settings → Secrets and Variables, never hardcoded in the workflow file. - The deploy step SSHes into your server and pulls the freshly built image, then restarts the service with Docker Compose. You could just as easily swap this for a deploy to AWS, a Kubernetes cluster, Vercel, or Netlify.
- Because this workflow only runs on pushes to
main, andmainis protected by the CI workflow's required checks, broken code effectively can't reach production.
Step 2.5: Keeping Docker Inside the GitHub Ecosystem
The example above pushes images to Docker Hub, which means juggling a separate account and separate secrets. If you'd rather keep everything under one roof, GitHub ships its own registry — GitHub Container Registry (ghcr.io) — and it authenticates using the token GitHub Actions already generates for you, GITHUB_TOKEN. No extra secrets to create or rotate.
First, a simple Dockerfile for context:
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
This is a multi-stage build — the first stage installs dependencies and compiles the app, the second stage copies over only what's needed to run it, keeping the final image small.
Now the GitHub Actions job that builds this image and pushes it straight to ghcr.io:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
A few details worth understanding:
-
permissions: packages: writegrants this specific job the right to publish to your repo's package registry — GitHub Actions permissions are scoped per-job, so you only unlock what you actually need. -
github.actorandsecrets.GITHUB_TOKENare both provided automatically by GitHub for every workflow run — there's no manual setup required to authenticate. -
docker/setup-buildx-actionenables BuildKit, which unlocks multi-platform builds (e.g., building for bothlinux/amd64andlinux/arm64in one step) and smarter layer caching. -
cache-from/cache-to: type=ghastores build layers in GitHub's own Actions cache, so unchanged layers (likenpm ciwhenpackage.jsonhasn't changed) are reused instead of rebuilt — often cutting build times dramatically. - Once pushed, the image shows up under your repository's Packages tab, versioned alongside your code and visible to anyone with repo access — no separate dashboard to check.
From here, the deploy step is the same idea as before: SSH into your server (or trigger a Kubernetes rollout, or hit a webhook) and tell it to pull ghcr.io/yourorg/yourapp:latest.
Step 3: Adding Environments and Approvals
For anything beyond a side project, you'll usually want a staging environment before production, and possibly a manual approval gate. GitHub Actions supports this natively through Environments:
jobs:
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.com
steps:
- run: echo "Deploying to production"
By configuring the production environment in your repo settings with required reviewers, this job will pause and wait for a human to click "approve" before it proceeds — giving you automation with a safety checkpoint where it matters most.
A Real-World Example: Backend + Frontend + VM Deploy
Toy examples are great for learning the syntax, but real applications are rarely a single job. A more realistic setup often has a separate backend and frontend, each with their own checks, that only get built and shipped once both pass. Here's how that looks stitched together as one pipeline.
1. Two independent jobs run checks in parallel.
env:
REGISTRY: ghcr.io
IMAGE_BACKEND: ghcr.io/myorg/webapp-backend
IMAGE_FRONTEND: ghcr.io/myorg/webapp-frontend
jobs:
backend:
name: Backend (typecheck + tests)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: backend/package-lock.json
- run: npm ci
- run: npx prisma generate
- run: npx tsc -b --noEmit
- run: npm test
frontend:
name: Frontend (lint + typecheck + tests + build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run lint
- run: npx tsc -b --noEmit
- run: npm test
- run: npm run build
Notice defaults.run.working-directory — this scopes every step in the job to a subfolder, which is exactly what you want in a monorepo where backend/ and frontend/ each have their own package.json. Because these two jobs share no dependency between them, GitHub Actions runs them in parallel, so a slow frontend build doesn't hold up backend tests.
2. A build job waits for both, then pushes two images.
build-and-push:
name: Build & Push Images
runs-on: ubuntu-latest
needs: [backend, frontend]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- uses: docker/setup-buildx-action@v3
- name: Build & push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile.prod
push: true
tags: |
${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max,ignore-error=true
- name: Build & push frontend image
uses: docker/build-push-action@v6
with:
context: ./frontend
file: ./frontend/Dockerfile.prod
push: true
tags: |
${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max,ignore-error=true
The needs: [backend, frontend] line is the key piece — this job simply won't start until both check jobs succeed. Tagging every image with both :latest and :${{ github.sha }} means you always have an immutable, traceable tag to roll back to, even after :latest has moved on.
3. The deploy job ships compose files, then runs everything over SSH.
deploy:
name: Deploy to VM
runs-on: ubuntu-latest
needs: build-and-push
steps:
- uses: actions/checkout@v5
- name: Copy docker-compose.prod.yml to VM
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VM_HOST }}
username: ${{ secrets.VM_USER }}
key: ${{ secrets.VM_SSH_KEY }}
source: docker-compose.prod.yml
target: ${{ secrets.VM_PROJECT_PATH }}
- name: Deploy on VM
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.VM_HOST }}
username: ${{ secrets.VM_USER }}
key: ${{ secrets.VM_SSH_KEY }}
script: |
set -e
cd "${{ secrets.VM_PROJECT_PATH }}"
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
cat > .env <<'EOF'
DATABASE_URL=postgresql://${{ secrets.DB_USER }}:${{ secrets.DB_PASSWORD }}@db:5432/${{ secrets.DB_NAME }}
IMAGE_TAG=${{ github.sha }}
EOF
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker compose -f docker-compose.prod.yml exec -T backend npx prisma db push --accept-data-loss --skip-generate
docker image prune -f
A few patterns here are worth stealing for your own pipelines:
-
scp-actionbeforessh-action. Config files likedocker-compose.prod.ymllive in your repo, not on the server, so the pipeline copies the current version over before every deploy — the server never drifts from what's checked in. -
Writing
.envon the fly with a heredoc. Nothing sensitive is ever stored on disk in your repo; secrets are injected straight from GitHub's encrypted store into a file that only exists for this deploy. -
IMAGE_TAG=${{ github.sha }}flows into the compose file's image reference, sodocker compose pullfetches the exact commit that just passed CI — never a stale or ambiguous:latest. -
--skip-generateonprisma db push. Small flag, real reason: if your Docker build already runsprisma generateinside the image, doing it again after every deploy is wasted work — and on a memory-constrained VM, generating engine binaries for multiple platforms can be enough to get the process OOM-killed. Know what your tooling repeats by default and turn off what you don't need. -
docker image prune -fat the end keeps old, now-unreferenced image layers from slowly filling the VM's disk over months of deploys.
None of this is exotic — it's the same three ideas from earlier (test → build → deploy) applied to a project with more moving parts. The complexity lives in the details of your stack (a database migration, a reverse proxy, a monorepo layout), not in GitHub Actions itself.
Practical Tips
-
Cache dependencies. Using
cache: 'npm'(or the equivalent for your package manager) can cut minutes off every run. - Fail fast. Put cheap checks (linting) before expensive ones (integration tests) so you get feedback sooner.
- Use matrix builds to test across multiple Node/Python versions or operating systems simultaneously.
-
Pin action versions (
@v4, not@main) so a third-party action update doesn't silently break your pipeline. - Separate build from deploy for anything beyond a static site — build once, deploy the same artifact to staging and production to avoid "it built differently" surprises.
Wrapping Up
At its core, a GitHub Actions CI/CD pipeline is just YAML describing the same steps you'd otherwise do by hand — install, test, build, ship — except now they run automatically, consistently, and with a full audit trail every single time. Start small: get tests running on pull requests first. Once that feels solid, layer in automated deployment, then environments and approval gates as your project grows.
The best part is that all of this lives in your repository alongside your code, versioned and reviewable just like everything else you ship.
Top comments (0)