CI/CD Pipelines with GitHub Actions: Ship a Web App Automatically (Tests → Build → Deploy)
If you’re still deploying by SSH’ing into a server and running random commands, you’re one typo away from downtime. A simple CI/CD pipeline gives you repeatable builds, reliable testing, and push-button (or push-to-main) deployments.
This hands-on guide walks you through a practical setup using GitHub Actions to:
- Run lint + tests on every pull request
- Build and package your app on merges to
main - Deploy to a server via SSH in a consistent way
We’ll use a typical Node.js web app (works for React/Next/Vite + a Node API). You can adapt the same pipeline concepts to other stacks.
What you’ll build
- CI (Continuous Integration): On every PR, run
npm ci,lint, andtest. - CD (Continuous Deployment): On merge to
main, build the app and deploy it to a VPS. - Safe deploy approach: Upload a versioned release archive, unpack to a new folder, then switch a
currentsymlink and restart.
This “release folder + symlink” pattern is simple and avoids “half-deployed” states.
Project prerequisites
Your repo should have:
package.jsonwithlint,test, andbuildscripts- A production start command (
npm run start) or a process manager (we’ll showsystemd)
Example package.json scripts:
{ "scripts": { "lint": "eslint .", "test": "vitest run", "build": "vite build", "start": "node server.js" } }
If you’re using Next.js, build and start would typically be next build and next start.
Step 1: Add a CI workflow for PRs
Create .github/workflows/ci.yml:
name: CI on: pull_request: branches: [ "main" ] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: Test run: npm test - name: Build (smoke check) run: npm run build
Why this is practical: “Build” in CI catches missing env vars, TypeScript issues, broken imports, and bundler errors before merge.
Step 2: Prepare your server for deployments
On your VPS (Ubuntu example), create a deploy user and a folder layout:
# as root adduser deploy usermod -aG sudo deploy mkdir -p /var/www/myapp/releases chown -R deploy:deploy /var/www/myapp
We’ll deploy each release into:
/var/www/myapp/releases/<timestamp-or-sha>/- A symlink
/var/www/myapp/currentpoints to the active release
Optional but recommended: run your app with systemd. Create /etc/systemd/system/myapp.service:
[Unit] Description=MyApp After=network.target [Service] Type=simple User=deploy WorkingDirectory=/var/www/myapp/current Environment=NODE_ENV=production ExecStart=/usr/bin/npm run start Restart=always RestartSec=3 [Install] WantedBy=multi-user.target
Enable it:
systemctl daemon-reload systemctl enable myapp systemctl start myapp
Now deployments only need to update current and restart the service.
Step 3: Create the deploy workflow (CD)
We’ll build on GitHub’s runner, pack the build output, upload it to the server, unpack it, switch symlink, restart.
Create .github/workflows/deploy.yml:
name: Deploy on: push: branches: [ "main" ] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Build run: npm run build # Package only what the server needs. # For a Vite/React SPA + Node server, you might ship: # - dist/ (frontend build) # - server.js # - package.json + package-lock.json - name: Create release archive run: | RELEASE="${GITHUB_SHA}" mkdir -p release cp -r dist release/dist cp server.js release/server.js cp package.json package-lock.json release/ tar -czf "release-${RELEASE}.tar.gz" -C release . - name: Upload archive to server uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} source: "release-${{ github.sha }}.tar.gz" target: "/var/www/myapp/releases" - name: Deploy on server (unpack, install, switch symlink) uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | set -euo pipefail APP_DIR="/var/www/myapp" RELEASES="$APP_DIR/releases" RELEASE="${{ github.sha }}" RELEASE_DIR="$RELEASES/$RELEASE" mkdir -p "$RELEASE_DIR" tar -xzf "$RELEASES/release-$RELEASE.tar.gz" -C "$RELEASE_DIR" cd "$RELEASE_DIR" npm ci --omit=dev ln -sfn "$RELEASE_DIR" "$APP_DIR/current" sudo systemctl restart myapp # Optional: keep only the last 5 releases cd "$RELEASES" ls -1dt */ | tail -n +6 | xargs -r rm -rf
Key idea: The server always runs from /var/www/myapp/current, and a deploy is just updating that symlink atomically.
Step 4: Add GitHub Secrets (the secure way)
In your GitHub repo: Settings → Secrets and variables → Actions, add:
DEPLOY_HOST: your server IP or hostnameDEPLOY_USER:deployDEPLOY_SSH_KEY: a private key that can SSH into the server
Create an SSH key pair locally (or in a secure environment):
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key
Add the public key to the server (as deploy):
mkdir -p ~/.ssh chmod 700 ~/.ssh echo "PASTE_PUBLIC_KEY_HERE" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys
Put the private key contents into DEPLOY_SSH_KEY in GitHub Secrets.
Step 5: Make deployments safer with basic checks
A junior-friendly improvement is to add quick health validation after restart. If you have a /health endpoint, add:
- name: Verify health endpoint uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | set -e curl -fsS http://localhost:3000/health
If curl fails, the workflow fails and you’ll see it immediately in GitHub Actions logs.
Common pitfalls (and how to avoid them)
- “Works locally, fails in CI”: Use
npm ci(notnpm install) so CI uses the lockfile exactly. - Missing environment variables: Keep secrets in GitHub Secrets; on the server, put non-secret config in systemd
Environment=or an env file read by the service. - Long installs on server: Shipping a prebuilt bundle (we do) keeps deploys fast; installing production dependencies is usually acceptable, but you can also package
node_modulesif needed (trade-offs apply). - Unbounded disk usage: Always prune old releases (we keep the last 5).
Upgrade path: staging, approvals, and rollbacks
Once the basic pipeline works, here are the next “real-world” steps:
- Staging first: Deploy PR merges to
develop→ staging, then promote to production onmain. - Manual approvals: Use GitHub Environments to require approval before production deploys.
- Rollback: Because releases are folders, rolling back is as simple as switching
currentto the previous release and restarting.
Example rollback command on the server:
cd /var/www/myapp/releases ls -1dt */ | head -n 2 # pick the previous one, then: ln -sfn /var/www/myapp/releases/PREVIOUS_SHA /var/www/myapp/current sudo systemctl restart myapp
Wrap-up
You now have a practical CI/CD pipeline that junior and mid developers can understand and maintain:
- PRs get fast feedback (lint, tests, build)
- Merges to
mainship automatically - Deploys are consistent, versioned, and easy to rollback
If you want, tell me your stack (Next.js? Laravel? FastAPI? Angular?) and your deployment target (VPS, AWS, Fly.io, Render, Kubernetes), and I’ll adapt the same pattern with the best-fit workflow and file layout.
Leave a Reply