Skip to main content

Command Palette

Search for a command to run...

Blog 8 — CI/CD: Automating Your Next.js Deployment with GitHub Actions

Updated
8 min read
Blog 8 — CI/CD: Automating Your Next.js Deployment with GitHub Actions

Linux for Everyday Use | Part 8 of the Linux → Deploy Series


In Blog 7, you ended up with this deploy routine:

ssh aakib@your-server-ip
~/deploy.sh

Two commands. Not bad. But every time you push a change, you have to remember to SSH in and run it. Forget once, and your live site is out of sync with your code. Multiply that across a team, and someone will forget — usually right before a demo.

CI/CD removes the human from that loop. You push to main, and within a minute or two, your change is live — built, tested, and deployed automatically. This blog wires that up using GitHub Actions, the CI/CD system built directly into GitHub.


What CI/CD Actually Means

CI (Continuous Integration) — every time code is pushed, it's automatically built and tested. If something breaks, you find out in minutes, not when a user reports it.

CD (Continuous Deployment) — if the build and tests pass, the code is automatically deployed to your server. No manual SSH session required.

Together, they turn git push into your entire deployment process.


How GitHub Actions Works

GitHub Actions runs workflows — automated sequences of steps — in response to events like a push or pull request. Workflows are defined in YAML files inside a .github/workflows/ directory in your repo.

When you push to main, GitHub spins up a temporary Linux machine (called a "runner"), checks out your code, and runs whatever steps you've defined: install dependencies, run tests, build the app, and — in our case — connect to your server and deploy.


Step 1 — Generate a Dedicated Deploy Key

Don't reuse your personal SSH key for automated deployments. Generate a separate key pair specifically for GitHub Actions to use.

On your local machine (or the server — either works):

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./gh_deploy_key -N ""

This creates two files: gh_deploy_key (private) and gh_deploy_key.pub (public).

Add the public key to your server

cat gh_deploy_key.pub

Copy the output, then on your server:

nano ~/.ssh/authorized_keys
# Paste the public key on a new line, save, exit

This authorizes that specific key pair to log in via SSH — separate from your personal key, so you can revoke it independently if needed.

Add the private key to GitHub Secrets

In your repo on GitHub: Settings → Secrets and variables → Actions → New repository secret.

Create these secrets:

Secret name Value
SSH_PRIVATE_KEY Contents of gh_deploy_key (the private key file)
SSH_HOST Your server's IP address or domain
SSH_USER Your SSH username (e.g. aakib)
DEPLOY_PATH /var/www/your-nextjs-app

Secrets are encrypted and never shown in logs — this is the safe way to give a workflow access to your server without exposing credentials in your code.


Step 2 — Write the Workflow File

Create the directory and file in your project:

mkdir -p .github/workflows
nano .github/workflows/deploy.yml

Paste this:

name: Deploy Next.js App

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'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run lint
        run: npm run lint

      - name: Build application
        run: npm run build

      - name: Deploy to server via SSH
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd ${{ secrets.DEPLOY_PATH }}
            git pull origin main
            npm ci
            npm run build
            pm2 restart nextjs-app

What each step does

  • Checkout code — pulls your repo's latest commit onto the runner

  • Set up Node.js — installs Node 20 and caches npm dependencies between runs (faster builds)

  • npm ci — like npm install, but stricter: it installs exactly what's in package-lock.json. This is the standard for CI environments because it guarantees reproducible builds

  • Run lint — catches code-quality issues before they reach production. If npm run lint fails, the workflow stops here — nothing gets deployed

  • Build application — runs npm run build, the same command you ran manually in Blog 7. This step exists so that a broken build fails the pipeline before touching your server

  • Deploy to server via SSH — uses the popular appleboy/ssh-action, which connects to your server using the secrets you configured, then runs the same four commands from your deploy.sh script

Notice the script step mirrors exactly what you did by hand: pull, install, build, restart. CI/CD isn't magic — it's just your manual process, automated and triggered on every push.


Step 3 — Push and Watch It Run

Commit the workflow file and push:

git add .github/workflows/deploy.yml
git commit -m "Add CI/CD pipeline for automatic deployment"
git push origin main

In your GitHub repo, click the Actions tab. You'll see your workflow running in real time — each step expands to show live logs, exactly like watching a terminal.

If everything is green, your app is now live with the latest changes — and you didn't touch the server once.

If a step fails (red ❌), click into it to see exactly which command errored and why. This is the same debugging skill from Blog 5 — reading logs to understand what broke — just surfaced inside GitHub's interface instead of journalctl.


Common Pitfalls and How to Avoid Them

"Permission denied (publickey)" during the SSH step The public key wasn't added correctly to ~/.ssh/authorized_keys on the server, or the private key secret has extra whitespace. Re-copy both carefully — SSH is unforgiving about formatting.

Build succeeds locally but fails in the workflow Usually a missing environment variable. Remember: the GitHub Actions runner is a brand-new machine with no .env file. If your build step needs environment variables (e.g., for static generation), add them as GitHub Secrets and reference them in the workflow:

      - name: Build application
        run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

Workflow runs but the live site doesn't change SSH into the server and check pm2 logs nextjs-app — if the restart succeeded but you're still seeing old content, your browser may be caching. Hard-refresh with Ctrl+Shift+R.

Deploys are slow The cache: 'npm' line in the setup-node step caches node_modules between runs, which significantly speeds up repeat builds. Make sure it's present.


Going Further

This pipeline triggers on every push to main — fine for a solo project, but risky for a team, since any merged change goes live immediately. A few natural next steps:

  • Require checks before merging — in GitHub branch protection settings, require the workflow to pass before a pull request can be merged into main

  • Staging environment — add a second workflow that deploys develop branch pushes to a staging server, so you can test before promoting to production

  • Notifications — add a step that posts to Slack or Discord when a deploy succeeds or fails, so the team knows without checking GitHub


Quick Reference

Concept What it means
CI (Continuous Integration) Auto-build and test on every push
CD (Continuous Deployment) Auto-deploy after CI passes
Workflow A YAML file defining automated steps
Runner The temporary machine that executes your workflow
npm ci Clean, reproducible install from lockfile
GitHub Secrets Encrypted values for credentials in workflows
appleboy/ssh-action Action that connects to your server over SSH

What's Next

You now have a deployment pipeline that runs itself. Push code, get a live site — no manual steps, no forgetting, no drift between what's in GitHub and what's running in production.

From here, the natural directions are Docker (package your app so it runs identically on any machine — including the GitHub Actions runner) and monitoring (get alerted the moment your app goes down, rather than finding out from a user). Both build directly on the Linux fundamentals — processes, services, logs, networking — that this series has covered from the ground up.