Deploying a Next.js application manually every time you make a change can quickly become repetitive.
A better approach is to set up a CI/CD pipeline that automatically deploys your application whenever you push new code to your GitHub repository.
In this guide, we'll build a practical CI/CD workflow using:
- Next.js
- GitHub Actions
- Docker
- Dockerfile
- SSH
- Ubuntu VPS
- Nginx
- HTTPS/SSL
The main focus of this guide is automated CI/CD deployment. Nginx is included only as the production reverse proxy that sits in front of the Dockerized Next.js application.
By the end of this tutorial, you will be able to push code to the main
branch and have GitHub Actions automatically connect to your VPS, update
the project, build a new Docker image, and restart the production
container.
What Is CI/CD?
CI/CD stands for Continuous Integration and Continuous Delivery/Deployment.
Instead of manually logging into your VPS after every update, a CI/CD pipeline can automate the deployment process.
Without CI/CD:
textDeveloper ↓ git push ↓ Login to VPS manually ↓ git pull ↓ Install dependencies ↓ Build application ↓ Restart application
With CI/CD:
textDeveloper ↓ git push origin main ↓ GitHub Actions ↓ SSH into VPS ↓ Pull latest code ↓ Build Docker image ↓ Restart Docker container ↓ Application Live
This makes deployments faster, more consistent, and less dependent on manual server work.
What We Are Going to Build
Our final CI/CD architecture will look like this:
textDeveloper │ │ git push origin main ▼ GitHub Repository │ ▼ GitHub Actions │ │ SSH ▼ Ubuntu VPS │ ├── Git │ ├── Docker │ └── Nginx │ ▼ Docker Container │ ▼ Next.js App
The actual deployment flow will be:
textgit push ↓ GitHub Actions starts ↓ SSH connection to VPS ↓ git fetch origin ↓ git reset --hard origin/main ↓ docker build ↓ Stop old container ↓ Remove old container ↓ Start new container ↓ Next.js application is live
Requirements
Before starting, make sure you have:
- A Next.js project
- A GitHub repository
- An Ubuntu VPS
- SSH access to your VPS
- Docker installed on the VPS
- Nginx installed on the VPS
- A domain name pointing to your VPS
- Git installed on the VPS
You should also have a working Next.js application before setting up CI/CD.
Step 1: Prepare Your Next.js Project
First, make sure your Next.js application works correctly locally.
For example:
bashpnpm install
Then run the development server:
bashpnpm dev
Open:
texthttp://localhost:3000
Once your application works correctly, prepare it for a production Docker deployment.
Step 2: Configure Next.js for Docker
Open your Next.js configuration file.
For a TypeScript configuration:
textnext.config.ts
Add:
typescriptimport type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", }; export default nextConfig;
If you're using next.config.js:
javascriptconst nextConfig = { output: "standalone", }; module.exports = nextConfig;
The standalone output creates a production-ready Next.js server that
works well with Docker.
Step 3: Create a Dockerfile
Create a Dockerfile in the root of your Next.js project:
bashnano Dockerfile
Add:
dockerfileFROM node:24-alpine AS base FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN corepack enable && pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 CMD ["node", "server.js"]
This uses a multi-stage Docker build.
The Dockerfile separates the process into:
- Dependency installation
- Next.js production build
- Production runtime
This keeps the final image cleaner and avoids carrying unnecessary build dependencies into the runtime image.
Step 4: Create a .dockerignore File
Create:
bashnano .dockerignore
Add:
textnode_modules .next .git .gitignore README.md Dockerfile .env .env.local npm-debug.log pnpm-debug.log
This prevents unnecessary files from being sent to the Docker build context.
Step 5: Test the Docker Build Locally
Before creating the CI/CD pipeline, test your Docker image locally.
Build the image:
bashdocker build -t nextjs-app .
Check the image:
bashdocker images
Run the container:
bashdocker run -d \ --name nextjs-app \ -p 3000:3000 \ nextjs-app
Check the container:
bashdocker ps
Then open:
texthttp://localhost:3000
If your application works correctly, the Docker setup is ready.
Step 6: Prepare the VPS
Connect to your VPS:
bashssh root@YOUR_VPS_IP
Update the server:
bashsudo apt update sudo apt upgrade -y
Install Git:
bashsudo apt install git -y
Check Git:
bashgit --version
Step 7: Install Docker on the VPS
If Docker is not already installed, run:
bashcurl -fsSL https://get.docker.com | sh
Check Docker:
bashdocker --version
Check whether Docker is running:
bashsystemctl status docker
If necessary, start Docker:
bashsystemctl start docker
Enable Docker to start automatically after a server reboot:
bashsystemctl enable docker
Step 8: Clone Your Next.js Project on the VPS
Create a directory for your application:
bashmkdir -p /var/www
Go to the directory:
bashcd /var/www
Clone your GitHub repository:
bashgit clone https://github.com/USERNAME/YOUR-REPOSITORY.git
Enter your project:
bashcd YOUR-REPOSITORY
Check the project:
bashls
You should see files such as:
textpackage.json pnpm-lock.yaml Dockerfile next.config.ts app/ public/
Step 9: Test the Docker Deployment on the VPS
Before automating deployment, make sure the application works on the VPS.
Build the Docker image:
bashdocker build -t nextjs-app .
Run the container:
bashdocker run -d \ --name nextjs-app \ --restart unless-stopped \ -p 3000:3000 \ nextjs-app
Check:
bashdocker ps
Test the application:
bashcurl http://127.0.0.1:3000
If you receive an HTML response, your Next.js application is running successfully inside Docker.
Step 10: Install Nginx
Nginx is not the CI/CD system.
It is used here as a reverse proxy to receive requests from your domain and forward them to the Next.js Docker container.
Install Nginx:
bashsudo apt update sudo apt install nginx -y
Check Nginx:
bashsystemctl status nginx
Your architecture will now look like:
textInternet ↓ Domain ↓ Nginx ↓ 127.0.0.1:3000 ↓ Docker Container ↓ Next.js
Step 11: Configure Nginx Reverse Proxy
Create a configuration file:
bashsudo nano /etc/nginx/sites-available/nextjs-app
Add:
nginxserver { listen 80; server_name example.com www.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
Replace example.com with your actual domain.
Step 12: Enable the Nginx Configuration
Create the symbolic link:
bashsudo ln -s /etc/nginx/sites-available/nextjs-app /etc/nginx/sites-enabled/
Test the configuration:
bashsudo nginx -t
You should see:
textsyntax is ok test is successful
Reload Nginx:
bashsudo systemctl reload nginx
Now your domain should forward requests to the Next.js Docker container.
Step 13: Configure HTTPS
Install Certbot:
bashsudo apt install certbot python3-certbot-nginx -y
Run:
bashsudo certbot --nginx -d example.com -d www.example.com
After SSL is configured, your application will be available through:
texthttps://example.com
Step 14: Create the GitHub Actions Workflow
Now we reach the main part of this tutorial: CI/CD.
GitHub Actions will automatically run whenever you push code to the
main branch.
Inside your project, create:
text.github/workflows/deploy.yml
You can create it using:
bashmkdir -p .github/workflows nano .github/workflows/deploy.yml
Add:
yamlname: Deploy Next.js on: push: branches: - main jobs: deploy: runs-on: ubuntu-latest steps: - name: Deploy to VPS uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USERNAME }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ secrets.VPS_PORT }} script: | set -e echo "===== DEPLOYMENT START =====" echo "User:" whoami echo "Home:" echo $HOME echo "Going to project directory..." cd /var/www/your-nextjs-project echo "Pulling latest code..." git fetch origin git reset --hard origin/main echo "Building Docker image..." docker build -t nextjs-app . echo "Stopping old container..." docker stop nextjs-app || true docker rm nextjs-app || true echo "Starting new container..." docker run -d \ --name nextjs-app \ --restart unless-stopped \ -p 3000:3000 \ nextjs-app echo "===== DEPLOYMENT COMPLETE ====="
Replace:
text/var/www/your-nextjs-project
with the actual project directory on your VPS.
Step 15: Understand What the GitHub Actions Workflow Does
Let's break down the workflow.
Trigger
yamlon: push: branches: - main
This means the workflow runs whenever code is pushed to the main
branch.
For example:
bashgit add . git commit -m "Update application" git push origin main
That push triggers the deployment workflow.
Connect to the VPS
yamluses: appleboy/ssh-action@v1.2.0
This action allows GitHub Actions to connect to your VPS using SSH.
The credentials come from GitHub Secrets:
yamlhost: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USERNAME }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ secrets.VPS_PORT }}
Pull the Latest Code
The workflow runs:
bashgit fetch origin git reset --hard origin/main
This makes the VPS project match the latest main branch.
Build the Docker Image
Next:
bashdocker build -t nextjs-app .
Docker reads the Dockerfile and creates a new production image
containing the latest version of the Next.js application.
Stop the Old Container
The workflow then runs:
bashdocker stop nextjs-app || true docker rm nextjs-app || true
This removes the previous running container.
The || true prevents the deployment from failing if the container does
not exist yet.
Start the New Container
Finally:
bashdocker run -d \ --name nextjs-app \ --restart unless-stopped \ -p 3000:3000 \ nextjs-app
The new version of your Next.js application starts inside Docker.
Step 16: Configure GitHub Secrets
Go to your GitHub repository:
textSettings → Secrets and variables → Actions → New repository secret
Create these secrets:
textVPS_HOST VPS_USERNAME VPS_SSH_KEY VPS_PORT
For example:
textVPS_HOST YOUR_VPS_IP VPS_USERNAME root VPS_PORT 22
For:
textVPS_SSH_KEY
add your private SSH key.
Never commit your private SSH key directly to your GitHub repository.
Step 17: Push the CI/CD Workflow
Commit the workflow:
bashgit add .github/workflows/deploy.yml
Create a commit:
bashgit commit -m "Add CI/CD deployment workflow"
Push it:
bashgit push origin main
GitHub Actions should now start automatically.
Step 18: Monitor the GitHub Actions Deployment
Open your GitHub repository and go to:
textGitHub Repository → Actions → Deploy Next.js
You should see the deployment job running.
A successful deployment should look like:
textDeploy Next.js ✓ Deploy to VPS
If the workflow fails, open the job logs to identify the failed command.
Step 19: Verify the Docker Container
After the GitHub Actions workflow completes successfully, connect to your VPS:
bashssh root@YOUR_VPS_IP
Check the container:
bashdocker ps
You should see:
textnextjs-app
Check application logs:
bashdocker logs nextjs-app
For live logs:
bashdocker logs -f nextjs-app
Step 20: Test the Production Website
Open your production domain:
texthttps://example.com
If the latest changes are visible, your CI/CD pipeline is working successfully.
Now every time you run:
bashgit push origin main
GitHub Actions will automatically deploy the new version to your VPS.
Complete CI/CD Architecture
Your final architecture is:
textDEVELOPER │ │ git push ▼ GITHUB REPOSITORY │ ▼ GITHUB ACTIONS │ │ SSH ▼ UBUNTU VPS │ ┌───────────┴───────────┐ │ │ ▼ ▼ Nginx Docker │ │ │ ▼ │ Next.js Container │ │ └──────────► Production App
The CI/CD pipeline itself is:
textDeveloper │ │ git push origin main ▼ GitHub │ ▼ GitHub Actions │ │ SSH ▼ VPS │ ▼ git fetch origin │ ▼ git reset --hard origin/main │ ▼ docker build │ ▼ Stop Old Container │ ▼ Remove Old Container │ ▼ Start New Container │ ▼ Production Updated
Common Problems and Solutions
GitHub Actions Cannot Connect to the VPS
Check:
VPS_HOSTVPS_USERNAMEVPS_PORTVPS_SSH_KEY
Also make sure SSH access works manually.
bashssh root@YOUR_VPS_IP
Docker Container Is Not Running
Check:
bashdocker ps -a
Then:
bashdocker logs nextjs-app
The logs should show why the container stopped.
Nginx Shows 502 Bad Gateway
First check Docker:
bashdocker ps
Then test Next.js directly:
bashcurl http://127.0.0.1:3000
If Next.js doesn't respond, check:
bashdocker logs nextjs-app
If Next.js works but Nginx doesn't, test the Nginx configuration:
bashsudo nginx -t
Port 3000 Is Already in Use
Check:
bashsudo lsof -i :3000
Also check Docker:
bashdocker ps
Stop the process or container that is already using the port.
GitHub Actions Deployment Fails During Docker Build
Check the Docker build manually on the VPS:
bashcd /var/www/your-nextjs-project docker build -t nextjs-app .
This makes it easier to identify whether the issue is with the Dockerfile or the CI/CD workflow.
Docker vs PM2 in CI/CD
PM2 and Docker can both be used to run a Next.js application, but they work differently.
With PM2:
textNginx ↓ PM2 ↓ Next.js
With Docker:
textNginx ↓ Docker ↓ Next.js
If you're using Docker for your production deployment, you generally don't need PM2 inside the Docker container.
Docker manages the container lifecycle, while Nginx acts as the reverse proxy.
For this CI/CD setup, the responsibilities are separated:
textGitHub Actions → Deployment automation Docker → Application container Nginx → Reverse proxy Next.js → Application VPS → Server infrastructure
Why Use CI/CD for Next.js?
A CI/CD pipeline provides several advantages.
Faster Deployments
You don't have to manually log into the VPS after every code change.
Fewer Manual Errors
The same deployment commands run every time.
Consistent Builds
Docker creates the application using the same environment and build process.
Easy Rollout
Pushing to the main branch can automatically trigger a production
deployment.
Better Developer Experience
Your workflow becomes:
bashgit add . git commit -m "Update feature" git push origin main
The rest is automated.
Security Best Practices
When creating a CI/CD pipeline, security is important.
Never Commit Secrets
Do not put passwords, private SSH keys, API keys, or production credentials inside your repository.
Use GitHub Secrets instead.
Use SSH Keys
SSH keys are preferable to password-based authentication for automated deployments.
Protect Your Main Branch
Consider enabling branch protection so that production deployments only happen after your required checks pass.
Keep Docker and Server Updated
Regularly update your VPS, Docker installation, and application dependencies.
Use HTTPS
Always serve production applications through HTTPS.
Final Deployment Workflow
Once everything is configured, your normal development workflow becomes very simple:
bashgit add . git commit -m "Update application" git push origin main
Then:
textGit Push ↓ GitHub Actions ↓ SSH → VPS ↓ Pull Latest Code ↓ Docker Build ↓ Stop Old Container ↓ Start New Container ↓ Nginx ↓ Next.js Live
You no longer need to manually run the deployment process after every update.
Conclusion
Setting up CI/CD for a Next.js application doesn't have to be complicated.
With GitHub Actions, Docker, SSH, and a VPS, you can automate the entire deployment process.
The final system separates each responsibility clearly:
- GitHub stores your source code.
- GitHub Actions automates deployment.
- SSH securely connects GitHub Actions to your VPS.
- Docker packages and runs your Next.js application.
- Nginx handles reverse proxying and production web traffic.
- Next.js runs your application.
The most important part is the automated pipeline:
textDeveloper ↓ git push origin main ↓ GitHub Actions ↓ SSH → VPS ↓ Pull latest code ↓ Docker build ↓ Restart container ↓ Application Live
Once this is configured, deploying a new version can be as simple as:
bashgit push origin main
Push your code → GitHub Actions runs → Docker rebuilds → VPS updates → Your Next.js application goes live automatically.