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
npmdependencies between runs (faster builds)npm ci — like
npm install, but stricter: it installs exactly what's inpackage-lock.json. This is the standard for CI environments because it guarantees reproducible buildsRun lint — catches code-quality issues before they reach production. If
npm run lintfails, the workflow stops here — nothing gets deployedBuild 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 serverDeploy 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 yourdeploy.shscript
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
mainStaging environment — add a second workflow that deploys
developbranch pushes to a staging server, so you can test before promoting to productionNotifications — 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.





