VPS Deploy with CI/CD + SSH Connection
Complete setup for deploying Next.js apps to VPS with GitHub Actions CI/CD pipeline.
Compile : GitHub Actions (7GB RAM) - no memory issues
Push : Docker image to ghcr.io (GitHub Container Registry)
Deploy : SSH to VPS, docker pull + run
Note : The VPS does NOT need a cloned repo. Everything runs inside Docker containers. No /var/www directory needed.
1. GitHub Actions Workflow
File: .github/workflows/deploy.yml
name : Build & Deploy to VPS
on :
push :
branches :
- main
workflow_dispatch :
env :
REGISTRY : ghcr.io
IMAGE_NAME : username/project-name # Must be lowercase
jobs :
build-and-deploy :
runs-on : ubuntu-latest
environment : Production
permissions :
contents : read
packages : write
steps :
- name : Checkout code
uses : actions/checkout@v4
- name : Set up Docker Buildx
uses : docker/setup-buildx-action@v4
- name : Log in to Container Registry
uses : docker/login-action@v3
with :
registry : ${{ env.REGISTRY }}
username : ${{ github.actor }}
password : ${{ secrets.GITHUB_TOKEN }}
- name : Build and push Docker image
uses : docker/build-push-action@v6
with :
context : .
push : true
tags : ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from : type=gha
cache-to : type=gha,mode=max
build-args : |
DATABASE_URL=${{ secrets.DATABASE_URL }}
- name : Deploy to VPS
uses : appleboy/ssh-action@v1.0.3
with :
host : ${{ secrets.SSH_HOST }}
port : ${{ secrets.SSH_PORT }}
username : ${{ secrets.SSH_USER }}
key : ${{ secrets.SSH_PRIVATE_KEY }}
script : |
echo "${{ secrets.DOCKER_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker image prune -f
docker network create project_network || true
docker stop project_app || true
docker rm project_app || true
docker run -d \
--name project_app \
--network project_network \
-p 3000:3000 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-e AUTH_SESSION_SECRET="${{ secrets.AUTH_SESSION_SECRET }}" \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker image prune -f runs after each pull to remove dangling images and prevent disk from filling up.
# Stage 1: Install dependencies
FROM node:24-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.12.3 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml .npmrc ./
RUN pnpm install --frozen-lockfile --prod
# Stage 2: Build application
FROM node:24-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.12.3 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
COPY . .
ENV NODE_ENV=production
ARG DATABASE_URL
ENV DATABASE_URL=${DATABASE_URL}
RUN pnpm db:generate
RUN NEXT_SKIP_SOURCE_MAP=true NODE_OPTIONS="--max-old-space-size=2048" pnpm build
# Stage 3: Production runner
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder /app/node_modules ./node_modules
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node" , "server.js" ]
const nextConfig : NextConfig = {
output : 'standalone' , // Required for Docker
reactStrictMode : false ,
serverExternalPackages : [ 'pg' , 'googleapis' ] ,
} ;
4. GitHub Secrets (Environment: Production)
SSH_HOST - VPS IP (e.g., 181.13.244.185)
SSH_PORT - SSH port (default 22)
SSH_USER - SSH username (usually root)
SSH_PRIVATE_KEY - SSH private key
DOCKER_TOKEN - GitHub Personal Access Token (for docker pull on VPS)
DATABASE_URL - Database connection string
AUTH_SESSION_SECRET - Session secret
# Docker login to ghcr.io
echo " GITHUB_TOKEN" | docker login ghcr.io -u USERNAME --password-stdin
# Create Docker network
docker network create project_network
# Create postgres container (only once - data persists via volume)
docker run -d \
--name project_postgres \
--network project_network \
-e POSTGRES_DB=project_db \
-e POSTGRES_USER=project_user \
-e POSTGRES_PASSWORD=YOUR_PASSWORD \
-v project_postgres_data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16-alpine
Important : The DATABASE_URL must use the container name as hostname:
postgresql://project_user:PASSWORD@project_postgres:5432/project_db
NOT @postgres: or @localhost: — Docker DNS resolves by container name.
6. Running Migrations (Production)
The standalone container does NOT have pnpm. Run migrations using tsx from node_modules:
docker exec project_app node node_modules/.bin/tsx src/db/migrate.ts
This runs src/db/migrate.ts which applies all pending Drizzle migrations.
File: /etc/nginx/sites-available/default
server {
listen 80 ;
server_name YOUR_IP_OR_DOMAIN;
return 301 https://$server_name$request_uri ;
}
server {
listen 443 ssl http2;
server_name YOUR_IP_OR_DOMAIN;
ssl_certificate /etc/nginx/certs/cert.crt;
ssl_certificate_key /etc/nginx/certs/key.key;
location / {
proxy_pass http ://localhost:3000 ;
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 ;
}
}
sudo nginx -t
sudo systemctl reload nginx
sudo mkdir -p /etc/nginx/certs
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/nginx/certs/key.key \
-out /etc/nginx/certs/cert.crt \
-subj " /C=AR/ST=STATE/L=CITY/O=Org/CN=YOUR_IP"
Let's Encrypt (once you have domain)
acme.sh --issue -d yourdomain.com --nginx
acme.sh --install-cert -d yourdomain.com \
--key-file /etc/nginx/ssl/private.key \
--fullchain-file /etc/nginx/ssl/fullchain.pem
Node.js 24 : Latest LTS, better performance than 22
2GB Node heap : Prevents OOM during CI build
Skip source maps : Saves ~30% memory, not needed in production
Standalone output : Required for Docker multi-stage build
CI compilation : Compiles in GitHub Actions (7GB), not on VPS (2GB)
docker image prune -f : Prevents disk from filling up with old images
9. Command Pattern: VPS SSH Access
PowerShell Function ($PROFILE)
function projectname { wsl bash - i - c ' projectname' }
Bash Alias (~/.bashrc in WSL)
alias projectname=' sshpass -p PASSWORD ssh -pPORT root@VPS_IP'
projectname # Opens SSH session to VPS instantly
# $PROFILE
function lvc { wsl bash - i - c ' lvc' }
# ~/.bashrc
alias lvc=' sshpass -p PASSWORD ssh -p5028 root@VPS_IP'