In this article, I’ll walk you through setting up a GitHub Actions workflow to automatically deploy a Next.js application to Vercel.
Continuous deployment helps streamline development and ensures every push is production-ready.
We’ll configure secrets, create a workflow file, and connect GitHub with Vercel for seamless deployments.
This setup enables automated builds and previews for every commit.
Let’s dive into building a simple CI/CD pipeline for your Next.js app.
What We'll Build
A complete Next.js application that:
- ✅ Runs on Ubuntu 24 on port 80 as a background service
- ✅ Uses TypeScript and Tailwind CSS
- ✅ Has automated testing with Jest
- ✅ Auto-deploys to Vercel on every push
- ✅ Includes GitHub Actions CI/CD pipeline
Tech Stack
- Next.js 14 (App Router)
- TypeScript
- Tailwind CSS
- PM2 (Process Manager)
- GitHub Actions
- Vercel
📋 Prerequisites
- Ubuntu 24.04 server (local or cloud)
- GitHub account
- Vercel account (free)
- Basic knowledge of Git and terminal
STEP 1: Install Node.js and Required Tools on local machine
# Update system
sudo apt update && sudo apt upgrade -y
# Install Node.js 20.x (LTS)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Verify installation
node --version # Should show v20.x.x
npm --version # Should show 10.x.x
# Install additional tools
sudo apt install -y git curl build-essential
# Install PM2 (process manager for background running)
sudo npm install -g pm2
# Verify PM2
pm2 --version
STEP 2: Create Next.js Application
# Create project directory
mkdir -p ~/nextjs-app
cd ~/nextjs-app
# Create Next.js app structure
# We'll create all files manually for complete control
Create Project Structure
# Create directories
mkdir -p app/{api/hello,components,lib,styles}
mkdir -p public/images
mkdir -p .github/workflows
# Create files
touch next.config.js
touch package.json
touch tsconfig.json
touch .gitignore
touch .env.local
touch README.md
touch ecosystem.config.js
Now, let's create each file with content:
File 1: package.json
cat > package.json << 'EOF'
{
"name": "nextjs-app",
"version": "1.0.0",
"description": "Complete Next.js application with CI/CD",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p 80",
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"next": "14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@types/node": "^20.10.6",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-next": "14.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
}
}
EOF
File 2: next.config.js
cat > next.config.js << 'EOF'
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: 'standalone',
poweredByHeader: false,
// Environment variables
env: {
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME || 'Next.js App',
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:80',
},
// Image optimization
images: {
domains: ['localhost'],
formats: ['image/avif', 'image/webp'],
},
// Headers
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
],
},
];
},
};
module.exports = nextConfig;
EOF
File 3: tsconfig.json
cat > tsconfig.json << 'EOF'
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
EOF
File 4: .gitignore
cat > .gitignore << 'EOF'
# Dependencies
node_modules/
/.pnp
.pnp.js
# Testing
/coverage
# Next.js
/.next/
/out/
.next
# Production
/build
dist
# Misc
.DS_Store
*.pem
.vercel
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
.env.production
# PM2
.pm2
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Logs
logs
*.log
EOF
File 5: .env.local
cat > .env.local << 'EOF'
# Application
NEXT_PUBLIC_APP_NAME=My Next.js App
NEXT_PUBLIC_API_URL=http://localhost:80
# Environment
NODE_ENV=development
EOF
File 6: app/layout.tsx
cat > app/layout.tsx << 'EOF'
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Next.js App',
description: 'Complete Next.js application with CI/CD',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
EOF
File 7: app/page.tsx
cat > app/page.tsx << 'EOF'
import Link from 'next/link'
export default function Home() {
return (
<main className="min-h-screen flex flex-col items-center justify-center p-24 bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-4xl w-full space-y-8 text-center">
<div className="space-y-4">
<h1 className="text-6xl font-bold text-gray-900">
Welcome to <span className="text-blue-600">Next.js</span>
</h1>
<p className="text-xl text-gray-600">
A complete Next.js application with CI/CD pipeline
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12">
<div className="p-6 border border-gray-200 rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow">
<h3 className="text-xl font-semibold mb-2">📦 Features</h3>
<p className="text-gray-600">
TypeScript, Tailwind CSS, API Routes
</p>
</div>
<div className="p-6 border border-gray-200 rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow">
<h3 className="text-xl font-semibold mb-2">🚀 Deploy</h3>
<p className="text-gray-600">
Automated deployment to Vercel
</p>
</div>
<div className="p-6 border border-gray-200 rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow">
<h3 className="text-xl font-semibold mb-2">🔧 CI/CD</h3>
<p className="text-gray-600">
GitHub Actions workflow included
</p>
</div>
</div>
<div className="mt-12 space-x-4">
<Link
href="/about"
className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
About Page
</Link>
<Link
href="/api/hello"
className="inline-block px-6 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-900 transition-colors"
>
Test API
</Link>
</div>
<div className="mt-8 text-sm text-gray-500">
<p>Running on port 80 • Managed by PM2</p>
</div>
</div>
</main>
)
}
EOF
File 8: app/about/page.tsx
mkdir -p app/about
cat > app/about/page.tsx << 'EOF'
import Link from 'next/link'
export default function About() {
return (
<main className="min-h-screen flex flex-col items-center justify-center p-24 bg-gradient-to-br from-purple-50 to-pink-100">
<div className="max-w-2xl w-full space-y-8">
<h1 className="text-5xl font-bold text-gray-900 text-center">
About This App
</h1>
<div className="bg-white p-8 rounded-lg shadow-md space-y-4">
<h2 className="text-2xl font-semibold text-gray-800">Features</h2>
<ul className="list-disc list-inside space-y-2 text-gray-600">
<li>Next.js 14 with App Router</li>
<li>TypeScript for type safety</li>
<li>Tailwind CSS for styling</li>
<li>API Routes for backend logic</li>
<li>PM2 for process management</li>
<li>GitHub Actions for CI/CD</li>
<li>Vercel deployment ready</li>
</ul>
</div>
<div className="text-center">
<Link
href="/"
className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
← Back to Home
</Link>
</div>
</div>
</main>
)
}
EOF
File 9: app/api/hello/route.ts
cat > app/api/hello/route.ts << 'EOF'
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({
message: 'Hello from Next.js API!',
timestamp: new Date().toISOString(),
status: 'success',
environment: process.env.NODE_ENV,
})
}
export async function POST(request: Request) {
const body = await request.json()
return NextResponse.json({
message: 'Data received successfully',
receivedData: body,
timestamp: new Date().toISOString(),
})
}
EOF
File 10: app/globals.css
cat > app/globals.css << 'EOF'
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
EOF
File 11: tailwind.config.ts
cat > tailwind.config.ts << 'EOF'
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [],
}
export default config
EOF
File 12: postcss.config.js
cat > postcss.config.js << 'EOF'
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
EOF
File 13: jest.config.js
cat > jest.config.js << 'EOF'
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
}
module.exports = createJestConfig(customJestConfig)
EOF
File 14: jest.setup.js
cat > jest.setup.js << 'EOF'
import '@testing-library/jest-dom'
EOF
File 15: tests/page.test.tsx
mkdir -p __tests__
cat > __tests__/page.test.tsx << 'EOF'
import { render, screen } from '@testing-library/react'
import Home from '@/app/page'
describe('Home Page', () => {
it('renders the main heading', () => {
render(<Home />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeInTheDocument()
})
it('contains welcome text', () => {
render(<Home />)
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument()
})
})
EOF
File 16: ecosystem.config.js (PM2 Configuration)
cat > ecosystem.config.js << 'EOF'
module.exports = {
apps: [
{
name: 'nextjs-app',
script: 'npm',
args: 'start',
cwd: './',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 80,
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
time: true,
},
],
}
EOF
# Create logs directory
mkdir -p logs
File 17: README.md
cat > README.md << 'EOF'
# Next.js Application
Complete Next.js application with CI/CD pipeline.
## Features
- ✅ Next.js 14 with App Router
- ✅ TypeScript
- ✅ Tailwind CSS
- ✅ API Routes
- ✅ Jest Testing
- ✅ PM2 Process Management
- ✅ GitHub Actions CI/CD
- ✅ Vercel Deployment
## Development
npm install
npm run dev
## Production
npm run build
npm start
## Testing
npm test
## Deployment
Automatically deploys to Vercel on push to main branch.
EOF
STEP 3: Install Dependencies
# Install all dependencies
npm install
# This will take a few minutes
STEP 4: Build the Application
# Build for production
npm run build
# Expected output:
# ✓ Compiled successfully
# ✓ Linting and checking validity of types
# ✓ Collecting page data
# ✓ Generating static pages
STEP 5: Run on Port 80 (Requires sudo)
Using PM2
# Build first
npm run build
# Give Node.js permission to bind to port 80
sudo setcap 'cap_net_bind_service=+ep' $(which node)
# Start with PM2
pm2 start ecosystem.config.js
# Check status
pm2 status
# View logs
pm2 logs nextjs-app
# Save PM2 process list
pm2 save
# Setup PM2 to start on system boot
pm2 startup
# Follow the command it outputs
STEP 6: Test the Application
# Test locally
curl http://localhost:80
# Or open in browser
# http://localhost:80
STEP 7: Push to GitHub
# Initialize git
git init
# Add all files
git add .
# Commit
git commit -m "Initial commit: Complete Next.js app with CI/CD"
# Add remote (replace with your repo)
git remote add origin git@github.com:YOUR_USERNAME/nextjs-app.git
# Push
git push -u origin main
STEP 8: Create GitHub Actions Workflow
# Create workflow file
cat > .github/workflows/deploy.yml << 'EOF'
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20.x'
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: nextjs-build
path: .next
retention-days: 7
deploy:
name: Deploy to Vercel
runs-on: ubuntu-latest
needs: [test, build]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
EOF
# Commit and push
git add .github/workflows/deploy.yml
git commit -m "Add GitHub Actions CI/CD workflow"
git push origin main
STEP 9: Setup Vercel
9.1: Install Vercel CLI
npm install -g vercel
9.2: Login to Vercel
vercel login
1. Select "Continue with GitHub"
2. Vercel will open a browser window
3. Browser URL: https://vercel.com/auth/github
4. Click "Authorize Vercel"
5. You'll see: "Successfully logged in!"
6. Return to terminal
7. Terminal shows: ✅ Success! GitHub authentication complete
9.3: Link Project to Vercel
vercel link
## Follow These Prompts Exactly:
### Prompt 1:
? Set up "~/nextjs-app"? (Y/n)
**Answer:** Type `y` or just press **Enter** (default is Yes)
### Prompt 2
? Which scope should contain your project?
❯ yourname (Personal Account)
your-team (Team Account)
**Answer:** Use arrow keys to select your account, then press **Enter**
### Prompt 3
? Link to existing project? (y/N)
**Answer:** Type `n` or just press **Enter** (we're creating new project)
### Prompt 4
? What's your project's name? (nextjs-app)
**Answer:** Press **Enter** (keep default name) or type a custom name
### Prompt 5
? In which directory is your code located? (./)
**Answer:** Press **Enter** (keep default `./`)
## Expected Success Output
✅ Linked to yourname/nextjs-app (created .vercel and added it to .gitignore)
9.4: Get Vercel Credentials
# Get Vercel Token
# Go to: https://vercel.com/account/tokens
# Click "Create Token"
# Name: GitHub Actions
# Scope: Full Account
# Copy the token
# Get Project ID and Org ID
cat .vercel/project.json
Output will look like:
{
"projectId": "prj_abc123xyz",
"orgId": "team_xyz789abc"
}
STEP 10: Add GitHub Secrets
Click: Settings → Secrets and variables → Actions
Click: "New repository secret"
Add three secrets:
| Secret Name | Value Description | Where to Get It |
|--------------------|-----------------------------|----------------------------------------------|
| VERCEL_TOKEN | Your Vercel personal token | https://vercel.com/account/tokens |
| VERCEL_ORG_ID | Vercel Organization ID | From `.vercel/project.json` → `cat .vercel/project.json` |
| VERCEL_PROJECT_ID | Vercel Project ID | From `.vercel/project.json` → `cat .vercel/project.json` |
STEP 11: Test Full Pipeline
# Make a change
echo "# Test deployment" >> README.md
# Commit and push
git add README.md
git commit -m "Test CI/CD pipeline"
git push origin main
# Watch workflow
# Go to: GitHub → Actions tab
# Watch the pipeline run:
# ✅ Test
# ✅ Build
# ✅ Deploy to Vercel
🎨 How to Make Changes and See Automatic Deployment
Understanding the Workflow
Local Machine (Ubuntu)
↓ (make changes)
↓ (git commit)
↓ (git push)
↓
GitHub Repository
↓ (triggers)
↓
GitHub Actions (if configured)
↓ (or directly)
↓
Vercel Deployment
↓ (automatic)
↓
Live Website Updated ✅
🚀 Quick Test: Make Your First Change
Change Homepage Text (Easiest)
# Navigate to project
cd ~/nextjs-app
# Edit the home page
nano app/page.tsx
📤 Push Changes to GitHub
# Check what changed
git status
# Stage the changes
git add app/page.tsx
# Commit with a message
git commit -m "Update homepage: Changed title and description"
# Push to GitHub
git push origin main
🔍 Watch the Automatic Deployment
Method 1: Watch on Vercel Dashboard
1. Open browser
2. Go to: https://vercel.com/dashboard
3. Click on your project: sowmiya-next-js-app
4. You'll see: "Building..."
5. Wait 30-60 seconds
6. Status changes to: "Ready" ✅
7. Click "Visit" to see your changes live!


Top comments (0)