# 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:

```bash
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):

```bash
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

```bash
cat gh_deploy_key.pub
```

Copy the output, then on your **server**:

```bash
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:

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

Paste this:

```yaml
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:

```bash
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:

```yaml
      - 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.
