name: CI/CD Pipeline
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint & Test
run: |
pnpm lint
pnpm test
- name: Build Next.js
run: pnpm build
- name: Copy Static Files to Standalone
run: |
cp -r public .next/standalone/public
cp -r .next/static .next/standalone/.next/static
- name: Deploy to VPS via SSH
uses: appleboy/rsync-action@v0.0.10
with:
host: ${{ secrets.VPS_IP }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: ".next/standalone/"
target: "/var/www/my-app/current"
delete: true
script: |
cd /var/www/my-app/current
pm2 reload my-app || pm2 start server.js --name "my-app"Dacă ai depășit limita de hobby pe Vercel sau pur și simplu vrei control total pe un VPS de la Hetzner sau DigitalOcean, CI/CD-ul devine rapid o durere de cap. Am mutat recent un proiect cu vreo 12k utilizatori lunari de pe o platformă gestionată pe o instanță proprie de 5$ și am captat tot procesul într-un pipeline simplu. În postarea asta îți arăt cum să configurezi GitHub Actions ca să ruleze lint, teste, build standalone și deploy prin SSH fără bătăi de cap.
Caching și viteze reale pe CI
La început, workflow-ul nostru dura aproape 6 minute pe un runner de GitHub. E enorm când ai 10-15 PR-uri pe zi și aștepți după fiecare commit. Secretul a fost caching-ul agresiv pentru pnpm și .next/cache. Când refolosești artefactele de build anterioare, timpul total a scăzut la doar 1 minut și 40 de secunde.
Nu rula niciodată npm install simplu în CI fără freeze pe lockfile. Folosește pnpm install --frozen-lockfile (sau npm ci), altfel te trezești că pipeline-ul trece în CI, dar pică în producție din cauza unui minor release buclucaș dintr-o dependență secundară. Sequence-ul e simplu: linting cu ESLint, teste unitare, iar dacă ambele trec, trecem la pasul de compilare.
Secretul: output: 'standalone' în Next.js
Aici greșește multă lume când mută Next.js pe VPS: copiază tot proiectul cu node_modules cu tot (sute de MB) și dau npm run build direct pe serverul de producție. Greșit. Serverul tău de producție ar trebui doar să execute codul deja extras, nu să consume resurse procesând bundle-uri.
Adaugi output: 'standalone' în next.config.js. Când rulezi next build, framework-ul creează un folder minimalist în .next/standalone care conține DOAR fișierele strict necesare pentru a rula serverul Node.js. Dimensiunea pachetului trimis pe rețea scade de la 800MB la sub 50MB.
După ce build-ul e gata în GitHub Actions, împachetezi folderul standalone. Atenție însă: directoarele public și .next/static trebuie copiate manual separat în folderul de destinație de pe VPS, pentru că pachetul standalone nu le include automat.
Trade-off-uri și ce am pățit pe producție
Hai să fim sinceri: deploy-ul pe VPS prin rsync și SSH nu vine la pachet cu tot confortul de pe Vercel. Nu ai atomic deployments out-of-the-box. Dacă dai direct pm2 restart în timp ce rsync încă scrie fișierele noi, s-ar putea ca un utilizator ghinionist să prindă o secundă de eroare 502 sau 404 pe asset-uri JS.
Cum am rezolvat asta? Fac deploy într-un folder temporar cu timestamp (/var/www/app/releases/RELEASE_ID), copiez asset-urile statice în locul lor, apoi schimb un symlink simbolic (/var/www/app/current) către release-ul nou și dau pm2 reload. În felul ăsta am obținut zero-downtime real fără să complic arhitectura cu Kubernetes sau Docker Swarm.
Marele dezavantaj? Curățenia pe server. Dacă nu scrii un script mic care să șteargă release-urile mai vechi de 5 versiuni, în două luni umpli SSD-ul VPS-ului. Eu păstrez ultimele 3 build-uri pe disc pentru un rollback instant prin SSH în caz că crapă ceva critic la 2 noaptea.
Voi cum gestionați deploy-urile pe servere proprii? Mergeți pe docker-compose cu Traefik sau tot pe scripturi clasice cu PM2 și rsync?