Skip to content

Instantly share code, notes, and snippets.

@FranprzDev
Last active April 20, 2026 07:13
Show Gist options
  • Select an option

  • Save FranprzDev/2dee2738aa6c684097cd4d713e75a4e2 to your computer and use it in GitHub Desktop.

Select an option

Save FranprzDev/2dee2738aa6c684097cd4d713e75a4e2 to your computer and use it in GitHub Desktop.
Recommended VPS Deploy with CI/CD + SSH Connection

Quick Start Checklist

Use this checklist for each new project:

1. GitHub Setup

  • Create .github/workflows/deploy.yml (use template above)
  • Create .env.example with required variables
  • Update next.config.ts with output: 'standalone'
  • Update Dockerfile with heap size and source map optimization

2. GitHub Secrets Setup

  • Go to Settings → Environments → Production
  • Add secrets:
    • SSH_HOST (VPS IP)
    • SSH_PORT (usually 22, or custom like 5028)
    • SSH_USER (usually root)
    • SSH_PRIVATE_KEY (SSH private key)
    • DATABASE_URL (database connection)

3. VPS Preparation

  • Verify Docker is installed: docker --version
  • Create app directory: mkdir -p /var/www/PROJECT_NAME
  • Docker login: echo TOKEN | docker login ghcr.io -u USERNAME --password-stdin
  • Generate SSL certificate (self-signed or Let's Encrypt)
  • Update Nginx config and reload

4. First Deployment

  • Merge PR to main branch
  • GitHub Actions triggers automatically
  • Monitor workflow in Actions tab
  • Check VPS: docker ps to see if container is running
  • Test: curl https://YOUR_IP (ignore certificate warning if self-signed)

GitHub Secrets Setup (Step by Step)

1. Navigate to Secrets

  1. Go to your repository on GitHub
  2. Click SettingsEnvironments
  3. Click New environment → name it Production

2. Add SSH Secrets

SSH_HOST

  • Value: Your VPS IP (e.g., 181.13.244.185)
  • Click Add secret

SSH_PORT

  • Value: Your SSH port (e.g., 22 or 5028)
  • Click Add secret

SSH_USER

  • Value: SSH username (usually root)
  • Click Add secret

SSH_PRIVATE_KEY

  • Value: Your private SSH key content
    # In WSL/Linux, get your key:
    cat ~/.ssh/id_rsa
    # Copy the entire output including BEGIN/END lines
  • Click Add secret

3. Add Database Secrets

DATABASE_URL

  • Value: postgresql://user:pass@host:5432/db_name
  • Click Add secret

4. Verify

  • All 5 secrets should appear in Production environment
  • Workflow will use these automatically

Monitoring Deployments

1. GitHub Actions Status

Monitor the workflow:

GitHub → Actions → Select latest run

Watch for:

  • ✅ Build and push (compiles Docker image)
  • ✅ Deploy to VPS (SSH and docker pull/run)

2. VPS Container Status

SSH to VPS and check:

# List running containers
docker ps

# See container logs
docker logs container_name

# Example for lvc project:
docker logs lvc_app

3. VPS Service Status

Check if app is running:

# Test app is listening on :3000
curl http://localhost:3000

# Check Nginx is forwarding correctly
curl https://YOUR_IP

4. Troubleshooting

Docker image pull fails:

# Verify you can authenticate
docker login ghcr.io

# Manually pull image
docker pull ghcr.io/username/project:latest

Container won't start:

# Check logs for errors
docker logs container_name

# Common issue: port already in use
lsof -i :3000  # Check what's using port 3000

Nginx not forwarding:

# Test Nginx config
sudo nginx -t

# Check if Nginx is running
sudo systemctl status nginx

# Reload Nginx
sudo systemctl reload nginx

Check VPS resources:

# Memory and CPU usage
docker stats container_name

# Disk space
df -h

# Running processes
ps aux | grep docker

5. Useful Commands

# Stop container
docker stop container_name

# Remove stopped container
docker rm container_name

# View image size
docker images

# Remove unused images
docker image prune

# SSH into running container
docker exec -it container_name sh

VPS Deploy with CI/CD + SSH Connection

Complete setup for deploying Next.js apps to VPS with GitHub Actions CI/CD pipeline.

Architecture

  • 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.

2. Dockerfile

# 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"]

3. next.config.ts

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

5. VPS Setup (One-Time)

# 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.

7. Nginx Configuration

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

8. HTTPS Setup

Self-Signed (temporary)

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

Key Optimizations

  • 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'

Usage

projectname  # Opens SSH session to VPS instantly

Example: lvc project

# $PROFILE
function lvc { wsl bash -i -c 'lvc' }
# ~/.bashrc
alias lvc='sshpass -p PASSWORD ssh -p5028 root@VPS_IP'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment