DEV Community

Cover image for From Push to Production: Automating Deployments with GitHub Actions
Alfat Jahan Rony
Alfat Jahan Rony

Posted on

From Push to Production: Automating Deployments with GitHub Actions

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, and main is 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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A few details worth understanding:

  • permissions: packages: write grants 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.actor and secrets.GITHUB_TOKEN are both provided automatically by GitHub for every workflow run — there's no manual setup required to authenticate.
  • docker/setup-buildx-action enables BuildKit, which unlocks multi-platform builds (e.g., building for both linux/amd64 and linux/arm64 in one step) and smarter layer caching.
  • cache-from/cache-to: type=gha stores build layers in GitHub's own Actions cache, so unchanged layers (like npm ci when package.json hasn'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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A few patterns here are worth stealing for your own pipelines:

  • scp-action before ssh-action. Config files like docker-compose.prod.yml live 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 .env on 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, so docker compose pull fetches the exact commit that just passed CI — never a stale or ambiguous :latest.
  • --skip-generate on prisma db push. Small flag, real reason: if your Docker build already runs prisma generate inside 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 -f at 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)